improvement(var-resolution): resolve variables with block name check and consolidate code (#1469)

* improvement(var-resolution): resolve variables with block name check and consolidate code

* fix tests

* fix type error

* fix var highlighting in kb tags

* fix kb tags
This commit is contained in:
Vikhyath Mondreti
2025-09-30 19:20:35 -07:00
committed by GitHub
parent 0d881ecc00
commit 0bf2bce368
15 changed files with 476 additions and 142 deletions
@@ -10,6 +10,7 @@ import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'
@@ -60,6 +61,7 @@ export function ComboBox({
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const emitTagSelection = useTagSelection(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const inputRef = useRef<HTMLInputElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
@@ -432,7 +434,10 @@ export function ComboBox({
style={{ right: '42px' }}
>
<div className='w-full truncate text-foreground' style={{ scrollbarWidth: 'none' }}>
{formatDisplayText(displayValue)}
{formatDisplayText(displayValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
{/* Chevron button */}
@@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface InputFormatField {
@@ -152,6 +153,8 @@ export function InputMapping({
setMapping(updated)
}
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
if (!selectedWorkflowId) {
return (
<div className='flex flex-col items-center justify-center rounded-lg border border-border/50 bg-muted/30 p-8 text-center'>
@@ -213,6 +216,7 @@ export function InputMapping({
blockId={blockId}
subBlockId={subBlockId}
disabled={isPreview || disabled}
accessiblePrefixes={accessiblePrefixes}
/>
)
})}
@@ -229,6 +233,7 @@ function InputMappingField({
blockId,
subBlockId,
disabled,
accessiblePrefixes,
}: {
fieldName: string
fieldType?: string
@@ -237,6 +242,7 @@ function InputMappingField({
blockId: string
subBlockId: string
disabled: boolean
accessiblePrefixes: Set<string> | undefined
}) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
@@ -318,7 +324,10 @@ function InputMappingField({
className='w-full whitespace-pre'
style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }}
>
{formatDisplayText(value)}
{formatDisplayText(value, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
@@ -7,6 +7,7 @@ import { formatDisplayText } from '@/components/ui/formatted-text'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
import { useTagSelection } from '@/hooks/use-tag-selection'
@@ -55,6 +56,9 @@ export function KnowledgeTagFilters({
// Use KB tag definitions hook to get available tags
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
// Get accessible prefixes for variable highlighting
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
// State for managing tag dropdown
const [activeTagDropdown, setActiveTagDropdown] = useState<{
rowIndex: number
@@ -314,7 +318,12 @@ export function KnowledgeTagFilters({
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>{formatDisplayText(cellValue)}</div>
<div className='whitespace-pre'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
</td>
@@ -11,6 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'
@@ -92,6 +93,7 @@ export function LongInput({
const overlayRef = useRef<HTMLDivElement>(null)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
// Use preview value when in preview mode, otherwise use store value or prop value
const baseValue = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
@@ -405,7 +407,10 @@ export function LongInput({
height: `${height}px`,
}}
>
{formatDisplayText(value?.toString() ?? '')}
{formatDisplayText(value?.toString() ?? '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
{/* Wand Button */}
@@ -11,6 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'
@@ -345,6 +346,8 @@ export function ShortInput({
}
}
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
return (
<>
<WandPromptBar
@@ -417,7 +420,10 @@ export function ShortInput({
>
{password && !isFocused
? '•'.repeat(value?.toString().length ?? 0)
: formatDisplayText(value?.toString() ?? '')}
: formatDisplayText(value?.toString() ?? '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
@@ -22,6 +22,7 @@ import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
interface Field {
id: string
@@ -80,6 +81,7 @@ export function FieldFormat({
const [cursorPosition, setCursorPosition] = useState(0)
const [activeFieldId, setActiveFieldId] = useState<string | null>(null)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
@@ -471,7 +473,10 @@ export function FieldFormat({
style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }}
>
{formatDisplayText(
(localValues[field.id] ?? field.value ?? '')?.toString()
(localValues[field.id] ?? field.value ?? '')?.toString(),
accessiblePrefixes
? { accessiblePrefixes }
: { highlightAll: true }
)}
</div>
</div>
@@ -24,6 +24,7 @@ import {
} from '@/components/ui/select'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTransport } from '@/lib/mcp/types'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
import { useMcpServersStore } from '@/stores/mcp-servers/store'
@@ -33,6 +34,7 @@ interface McpServerModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onServerCreated?: () => void
blockId: string
}
interface McpServerFormData {
@@ -42,7 +44,12 @@ interface McpServerFormData {
headers?: Record<string, string>
}
export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServerModalProps) {
export function McpServerModal({
open,
onOpenChange,
onServerCreated,
blockId,
}: McpServerModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [formData, setFormData] = useState<McpServerFormData>({
@@ -262,6 +269,8 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe
workspaceId,
])
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[600px]'>
@@ -337,7 +346,10 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe
className='whitespace-nowrap'
style={{ transform: `translateX(-${urlScrollLeft}px)` }}
>
{formatDisplayText(formData.url || '')}
{formatDisplayText(formData.url || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
@@ -389,7 +401,10 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe
transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`,
}}
>
{formatDisplayText(key || '')}
{formatDisplayText(key || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
@@ -417,7 +432,10 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe
transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`,
}}
>
{formatDisplayText(value || '')}
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
@@ -1977,6 +1977,7 @@ export function ToolInput({
// Refresh MCP tools when a new server is created
refreshTools(true)
}}
blockId={blockId}
/>
</div>
)
@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import { shallow } from 'zustand/shallow'
import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references'
import { normalizeBlockName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
export function useAccessibleReferencePrefixes(blockId?: string | null): Set<string> | undefined {
const { blocks, edges, loops, parallels } = useWorkflowStore(
(state) => ({
blocks: state.blocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
}),
shallow
)
return useMemo(() => {
if (!blockId) {
return undefined
}
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
if (starterBlock) {
accessibleIds.add(starterBlock.id)
}
const loopValues = Object.values(loops as Record<string, Loop>)
loopValues.forEach((loop) => {
if (!loop?.nodes) return
if (loop.nodes.includes(blockId)) {
loop.nodes.forEach((nodeId) => accessibleIds.add(nodeId))
}
})
const parallelValues = Object.values(parallels as Record<string, Parallel>)
parallelValues.forEach((parallel) => {
if (!parallel?.nodes) return
if (parallel.nodes.includes(blockId)) {
parallel.nodes.forEach((nodeId) => accessibleIds.add(nodeId))
}
})
const prefixes = new Set<string>()
accessibleIds.forEach((id) => {
prefixes.add(normalizeBlockName(id))
const block = blocks[id]
if (block?.name) {
prefixes.add(normalizeBlockName(block.name))
}
})
SYSTEM_REFERENCE_PREFIXES.forEach((prefix) => prefixes.add(prefix))
return prefixes
}, [blockId, blocks, edges, loops, parallels])
}
+34 -12
View File
@@ -1,28 +1,50 @@
'use client'
import type { ReactNode } from 'react'
import { normalizeBlockName } from '@/stores/workflows/utils'
export interface HighlightContext {
accessiblePrefixes?: Set<string>
highlightAll?: boolean
}
const SYSTEM_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
/**
* Formats text by highlighting block references (<...>) and environment variables ({{...}})
* Used in code editor, long inputs, and short inputs for consistent syntax highlighting
*
* @param text The text to format
*/
export function formatDisplayText(text: string): ReactNode[] {
export function formatDisplayText(text: string, context?: HighlightContext): ReactNode[] {
if (!text) return []
const shouldHighlightPart = (part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
return false
}
if (context?.highlightAll) {
return true
}
const inner = part.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeBlockName(prefix)
if (SYSTEM_PREFIXES.has(normalizedPrefix)) {
return true
}
if (context?.accessiblePrefixes?.has(normalizedPrefix)) {
return true
}
return false
}
const parts = text.split(/(<[^>]+>|\{\{[^}]+\}\})/g)
return parts.map((part, index) => {
if (part.startsWith('<') && part.endsWith('>')) {
return (
<span key={index} className='text-blue-500'>
{part}
</span>
)
}
if (part.match(/^\{\{[^}]+\}\}$/)) {
if (shouldHighlightPart(part) || part.match(/^\{\{[^}]+\}\}$/)) {
return (
<span key={index} className='text-blue-500'>
{part}
+98 -51
View File
@@ -1,13 +1,12 @@
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { shallow } from 'zustand/shallow'
import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format'
import { cn } from '@/lib/utils'
import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/block-outputs'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { Serializer } from '@/serializer'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type { Variable } from '@/stores/panel/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -25,6 +24,15 @@ interface BlockTagGroup {
distance: number
}
interface NestedBlockTagGroup extends BlockTagGroup {
nestedTags: Array<{
key: string
display: string
fullTag?: string
children?: Array<{ key: string; display: string; fullTag: string }>
}>
}
interface TagDropdownProps {
visible: boolean
onSelect: (newValue: string) => void
@@ -70,6 +78,18 @@ const normalizeVariableName = (variableName: string): string => {
return variableName.replace(/\s+/g, '')
}
const ensureRootTag = (tags: string[], rootTag: string): string[] => {
if (!rootTag) {
return tags
}
if (tags.includes(rootTag)) {
return tags
}
return [rootTag, ...tags]
}
const getSubBlockValue = (blockId: string, property: string): any => {
return useSubBlockStore.getState().getValue(blockId, property)
}
@@ -300,12 +320,27 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const [parentHovered, setParentHovered] = useState<string | null>(null)
const [submenuHovered, setSubmenuHovered] = useState(false)
const blocks = useWorkflowStore((state) => state.blocks)
const loops = useWorkflowStore((state) => state.loops)
const parallels = useWorkflowStore((state) => state.parallels)
const edges = useWorkflowStore((state) => state.edges)
const { blocks, edges, loops, parallels } = useWorkflowStore(
(state) => ({
blocks: state.blocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
}),
shallow
)
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const rawAccessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const combinedAccessiblePrefixes = useMemo(() => {
if (!rawAccessiblePrefixes) return new Set<string>()
const normalized = new Set<string>(rawAccessiblePrefixes)
normalized.add(normalizeBlockName(blockId))
return normalized
}, [rawAccessiblePrefixes, blockId])
// Subscribe to live subblock values for the active workflow to react to input format changes
const workflowSubBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
@@ -325,7 +360,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
)
const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId)
const variables = useVariablesStore((state) => state.variables)
const workflowVariables = workflowId ? getVariablesByWorkflowId(workflowId) : []
const searchTerm = useMemo(() => {
@@ -336,8 +370,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const {
tags,
variableInfoMap = {},
blockTagGroups = [],
variableInfoMap,
blockTagGroups: computedBlockTagGroups,
}: {
tags: string[]
variableInfoMap: Record<string, { type: string; id: string }>
blockTagGroups: BlockTagGroup[]
} = useMemo(() => {
if (activeSourceBlockId) {
const sourceBlock = blocks[activeSourceBlockId]
@@ -481,6 +519,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}
blockTags = ensureRootTag(blockTags, normalizedBlockName)
const shouldShowRootTag = sourceBlock.type === 'generic_webhook'
if (!shouldShowRootTag) {
blockTags = blockTags.filter((tag) => tag !== normalizedBlockName)
}
const blockTagGroups: BlockTagGroup[] = [
{
blockName,
@@ -507,18 +551,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}
const serializer = new Serializer()
const serializedWorkflow = serializer.serializeWorkflow(blocks, edges, loops, parallels)
const accessibleBlockIds = BlockPathCalculator.findAllPathNodes(
serializedWorkflow.connections,
blockId
)
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
if (starterBlock && !accessibleBlockIds.includes(starterBlock.id)) {
accessibleBlockIds.push(starterBlock.id)
}
const blockDistances: Record<string, number> = {}
if (starterBlock) {
@@ -623,6 +656,10 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockTagGroups: BlockTagGroup[] = []
const allBlockTags: string[] = []
// Use the combinedAccessiblePrefixes to iterate through accessible blocks
const accessibleBlockIds = combinedAccessiblePrefixes
? Array.from(combinedAccessiblePrefixes)
: []
for (const accessibleBlockId of accessibleBlockIds) {
const accessibleBlock = blocks[accessibleBlockId]
if (!accessibleBlock) continue
@@ -648,7 +685,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const normalizedBlockName = normalizeBlockName(blockName)
const outputPaths = generateOutputPaths(mockConfig.outputs)
const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
let blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = ensureRootTag(blockTags, normalizedBlockName)
blockTagGroups.push({
blockName,
@@ -750,6 +788,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}
blockTags = ensureRootTag(blockTags, normalizedBlockName)
const shouldShowRootTag = accessibleBlock.type === 'generic_webhook'
if (!shouldShowRootTag) {
blockTags = blockTags.filter((tag) => tag !== normalizedBlockName)
}
blockTagGroups.push({
blockName,
blockId: accessibleBlockId,
@@ -781,51 +825,54 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
return {
tags: [...variableTags, ...contextualTags, ...allBlockTags],
tags: [...allBlockTags, ...variableTags, ...contextualTags],
variableInfoMap,
blockTagGroups: finalBlockTagGroups,
}
}, [
activeSourceBlockId,
combinedAccessiblePrefixes,
blockId,
blocks,
edges,
getMergedSubBlocks,
loops,
parallels,
blockId,
activeSourceBlockId,
workflowVariables,
workflowSubBlockValues,
getMergedSubBlocks,
workflowId,
])
const filteredTags = useMemo(() => {
if (!searchTerm) return tags
return tags.filter((tag: string) => tag.toLowerCase().includes(searchTerm))
return tags.filter((tag) => tag.toLowerCase().includes(searchTerm))
}, [tags, searchTerm])
const { variableTags, filteredBlockTagGroups } = useMemo(() => {
const varTags: string[] = []
filteredTags.forEach((tag) => {
filteredTags.forEach((tag: string) => {
if (tag.startsWith(TAG_PREFIXES.VARIABLE)) {
varTags.push(tag)
}
})
const filteredBlockTagGroups = blockTagGroups
.map((group) => ({
const filteredBlockTagGroups = computedBlockTagGroups
.map((group: BlockTagGroup) => ({
...group,
tags: group.tags.filter((tag) => !searchTerm || tag.toLowerCase().includes(searchTerm)),
tags: group.tags.filter(
(tag: string) => !searchTerm || tag.toLowerCase().includes(searchTerm)
),
}))
.filter((group) => group.tags.length > 0)
.filter((group: BlockTagGroup) => group.tags.length > 0)
return {
variableTags: varTags,
filteredBlockTagGroups,
}
}, [filteredTags, blockTagGroups, searchTerm])
}, [filteredTags, computedBlockTagGroups, searchTerm])
const nestedBlockTagGroups = useMemo(() => {
return filteredBlockTagGroups.map((group) => {
const nestedBlockTagGroups: NestedBlockTagGroup[] = useMemo(() => {
return filteredBlockTagGroups.map((group: BlockTagGroup) => {
const nestedTags: Array<{
key: string
display: string
@@ -839,7 +886,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
> = {}
const directTags: Array<{ key: string; display: string; fullTag: string }> = []
group.tags.forEach((tag) => {
group.tags.forEach((tag: string) => {
const tagParts = tag.split('.')
if (tagParts.length >= 3) {
const parent = tagParts[1]
@@ -899,8 +946,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
visualTags.push(...variableTags)
nestedBlockTagGroups.forEach((group) => {
group.nestedTags.forEach((nestedTag) => {
nestedBlockTagGroups.forEach((group: NestedBlockTagGroup) => {
group.nestedTags.forEach((nestedTag: any) => {
if (nestedTag.children && nestedTag.children.length > 0) {
const firstChild = nestedTag.children[0]
if (firstChild.fullTag) {
@@ -952,8 +999,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (tag.startsWith(TAG_PREFIXES.VARIABLE)) {
const variableName = tag.substring(TAG_PREFIXES.VARIABLE.length)
const variableObj = Object.values(variables).find(
(v) => v.name.replace(/\s+/g, '') === variableName
const variableObj = workflowVariables.find(
(v: Variable) => v.name.replace(/\s+/g, '') === variableName
)
if (variableObj) {
@@ -985,7 +1032,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
onSelect(newValue)
onClose?.()
},
[inputValue, cursorPosition, variables, onSelect, onClose]
[inputValue, cursorPosition, workflowVariables, onSelect, onClose]
)
useEffect(() => setSelectedIndex(0), [searchTerm])
@@ -1030,7 +1077,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (selectedIndex < 0 || selectedIndex >= orderedTags.length) return null
const selectedTag = orderedTags[selectedIndex]
for (let gi = 0; gi < nestedBlockTagGroups.length; gi++) {
const group = nestedBlockTagGroups[gi]
const group = nestedBlockTagGroups[gi]!
for (let ni = 0; ni < group.nestedTags.length; ni++) {
const nestedTag = group.nestedTags[ni]
if (nestedTag.children && nestedTag.children.length > 0) {
@@ -1051,16 +1098,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
return
}
const currentGroup = nestedBlockTagGroups.find((group) => {
const currentGroup = nestedBlockTagGroups.find((group: NestedBlockTagGroup) => {
return group.nestedTags.some(
(tag, index) =>
(tag: any, index: number) =>
`${group.blockId}-${tag.key}` === currentHovered.tag &&
index === currentHovered.index
)
})
const currentNestedTag = currentGroup?.nestedTags.find(
(tag, index) =>
(tag: any, index: number) =>
`${currentGroup.blockId}-${tag.key}` === currentHovered.tag &&
index === currentHovered.index
)
@@ -1089,8 +1136,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
e.preventDefault()
e.stopPropagation()
if (submenuIndex >= 0 && submenuIndex < children.length) {
const selectedChild = children[submenuIndex]
handleTagSelect(selectedChild.fullTag, currentGroup)
const selectedChild = children[submenuIndex] as any
handleTagSelect(selectedChild.fullTag, currentGroup as BlockTagGroup | undefined)
}
break
case 'Escape':
@@ -1324,7 +1371,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
{nestedBlockTagGroups.length > 0 && (
<>
{variableTags.length > 0 && <div className='my-0' />}
{nestedBlockTagGroups.map((group) => {
{nestedBlockTagGroups.map((group: NestedBlockTagGroup) => {
const blockConfig = getBlock(group.blockType)
let blockColor = blockConfig?.bgColor || BLOCK_COLORS.DEFAULT
@@ -1340,7 +1387,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
{group.blockName}
</div>
<div>
{group.nestedTags.map((nestedTag, index) => {
{group.nestedTags.map((nestedTag: any, index: number) => {
const tagIndex = nestedTag.fullTag
? (tagIndexMap.get(nestedTag.fullTag) ?? -1)
: -1
@@ -1505,7 +1552,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}}
>
<div className='py-1'>
{nestedTag.children!.map((child, childIndex) => {
{nestedTag.children!.map((child: any, childIndex: number) => {
const isKeyboardSelected =
inSubmenu && submenuIndex === childIndex
const isSelected = isKeyboardSelected
+12 -11
View File
@@ -1356,7 +1356,7 @@ describe('InputResolver', () => {
expect(result.code).toBe('return "Agent response"')
})
it('should reject references to unconnected blocks', () => {
it('should leave references to unconnected blocks as strings', () => {
// Create a new block that is added to the workflow but not connected to isolated-block
workflowWithConnections.blocks.push({
id: 'test-block',
@@ -1402,9 +1402,9 @@ describe('InputResolver', () => {
enabled: true,
}
expect(() => connectionResolver.resolveInputs(testBlock, contextWithConnections)).toThrow(
/Block "isolated-block" is not connected to this block/
)
// Should not throw - inaccessible references remain as strings
const result = connectionResolver.resolveInputs(testBlock, contextWithConnections)
expect(result.code).toBe('return <isolated-block.content>') // Reference remains as-is
})
it('should always allow references to starter block', () => {
@@ -1546,7 +1546,7 @@ describe('InputResolver', () => {
expect(otherResult).toBe('content: Hello World')
})
it('should provide helpful error messages for unconnected blocks', () => {
it('should not throw for unconnected blocks and leave references as strings', () => {
// Create a test block in the workflow first
workflowWithConnections.blocks.push({
id: 'test-block-2',
@@ -1592,9 +1592,9 @@ describe('InputResolver', () => {
enabled: true,
}
expect(() => connectionResolver.resolveInputs(testBlock, contextWithConnections)).toThrow(
/Available connected blocks:.*Agent Block.*Start/
)
// Should not throw - references to nonexistent blocks remain as strings
const result = connectionResolver.resolveInputs(testBlock, contextWithConnections)
expect(result.code).toBe('return <nonexistent.value>') // Reference remains as-is
})
it('should work with block names and normalized names', () => {
@@ -1725,7 +1725,7 @@ describe('InputResolver', () => {
extendedResolver.resolveInputs(block1, extendedContext)
}).not.toThrow()
// Should fail for indirect connection
// Should not fail for indirect connection - reference remains as string
expect(() => {
// Add the response block to the workflow so it can be validated properly
extendedWorkflow.blocks.push({
@@ -1748,8 +1748,9 @@ describe('InputResolver', () => {
outputs: {},
enabled: true,
}
extendedResolver.resolveInputs(block2, extendedContext)
}).toThrow(/Block "agent-1" is not connected to this block/)
const result = extendedResolver.resolveInputs(block2, extendedContext)
expect(result.test).toBe('<agent-1.content>') // Reference remains as-is since agent-1 is not accessible
}).not.toThrow()
})
it('should handle blocks in same loop referencing each other', () => {
+58 -51
View File
@@ -1,11 +1,13 @@
import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { createLogger } from '@/lib/logs/console/logger'
import { VariableManager } from '@/lib/variables/variable-manager'
import { extractReferencePrefixes, SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references'
import { TRIGGER_REFERENCE_ALIAS_MAP } from '@/lib/workflows/triggers'
import { getBlock } from '@/blocks/index'
import type { LoopManager } from '@/executor/loops/loops'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { normalizeBlockName } from '@/stores/workflows/utils'
const logger = createLogger('InputResolver')
@@ -461,64 +463,40 @@ export class InputResolver {
return value
}
const blockMatches = value.match(/<([^>]+)>/g)
if (!blockMatches) return value
const blockMatches = extractReferencePrefixes(value)
if (blockMatches.length === 0) return value
// Filter out patterns that are clearly not variable references (e.g., comparison operators)
const validBlockMatches = blockMatches.filter((match) => this.isValidVariableReference(match))
// If no valid matches found after filtering, return original value
if (validBlockMatches.length === 0) {
return value
}
// If we're in an API block body, check each valid match to see if it looks like XML rather than a reference
if (
currentBlock.metadata?.id === 'api' &&
validBlockMatches.some((match) => {
const innerContent = match.slice(1, -1)
// Patterns that suggest this is XML, not a block reference:
return (
innerContent.includes(':') || // namespaces like soap:Envelope
innerContent.includes('=') || // attributes like xmlns="http://..."
innerContent.includes(' ') || // any space indicates attributes
innerContent.includes('/') || // self-closing tags
!innerContent.includes('.')
) // block refs always have dots
})
) {
return value // Likely XML content, return unchanged
}
const accessiblePrefixes = this.getAccessiblePrefixes(currentBlock)
let resolvedValue = value
// Check if we're in a template literal for function blocks
const isInTemplateLiteral =
currentBlock.metadata?.id === 'function' &&
value.includes('${') &&
value.includes('}') &&
value.includes('`')
for (const match of validBlockMatches) {
// Skip variables - they've already been processed
if (match.startsWith('<variable.')) {
for (const match of blockMatches) {
const { raw, prefix } = match
if (!accessiblePrefixes.has(prefix)) {
continue
}
const path = match.slice(1, -1)
const [blockRef, ...pathParts] = path.split('.')
if (raw.startsWith('<variable.')) {
continue
}
const path = raw.slice(1, -1)
const [blockRefToken, ...pathParts] = path.split('.')
const blockRef = blockRefToken.trim()
// Skip XML-like tags (but allow block names with spaces)
if (blockRef.includes(':')) {
continue
}
// System references (start, loop, parallel, variable) are handled as special cases
const isSystemReference = ['start', 'loop', 'parallel', 'variable'].includes(
blockRef.toLowerCase()
)
// Check if we're in a template literal context
const isInTemplateLiteral =
currentBlock.metadata?.id === 'function' &&
value.includes('${') &&
value.includes('}') &&
value.includes('`')
// System references and regular block references are both processed
// System references (start, loop, parallel, variable) and regular block references are both processed
// Accessibility validation happens later in validateBlockReference
// Special case for trigger block references (start, api, chat, manual)
@@ -657,7 +635,7 @@ export class InputResolver {
}
}
resolvedValue = resolvedValue.replace(match, formattedValue)
resolvedValue = resolvedValue.replace(raw, formattedValue)
continue
}
}
@@ -678,7 +656,7 @@ export class InputResolver {
)
if (formattedValue !== null) {
resolvedValue = resolvedValue.replace(match, formattedValue)
resolvedValue = resolvedValue.replace(raw, formattedValue)
continue
}
}
@@ -699,7 +677,7 @@ export class InputResolver {
)
if (formattedValue !== null) {
resolvedValue = resolvedValue.replace(match, formattedValue)
resolvedValue = resolvedValue.replace(raw, formattedValue)
continue
}
}
@@ -723,7 +701,7 @@ export class InputResolver {
const isInActivePath = context.activeExecutionPath.has(sourceBlock.id)
if (!isInActivePath) {
resolvedValue = resolvedValue.replace(match, '')
resolvedValue = resolvedValue.replace(raw, '')
continue
}
@@ -753,14 +731,14 @@ export class InputResolver {
const isInLoop = this.loopsByBlockId.has(sourceBlock.id)
if (isInLoop) {
resolvedValue = resolvedValue.replace(match, '')
resolvedValue = resolvedValue.replace(raw, '')
continue
}
// If the block hasn't been executed and isn't in the active path,
// it means it's in an inactive branch - return empty string
if (!context.activeExecutionPath.has(sourceBlock.id)) {
resolvedValue = resolvedValue.replace(match, '')
resolvedValue = resolvedValue.replace(raw, '')
continue
}
@@ -861,7 +839,7 @@ export class InputResolver {
: String(replacementValue)
}
resolvedValue = resolvedValue.replace(match, formattedValue)
resolvedValue = resolvedValue.replace(raw, formattedValue)
}
return resolvedValue
@@ -1362,6 +1340,18 @@ export class InputResolver {
if (!sourceBlock) {
const normalizedRef = this.normalizeBlockName(blockRef)
sourceBlock = this.blockByNormalizedName.get(normalizedRef)
if (!sourceBlock) {
for (const candidate of this.workflow.blocks) {
const candidateName = candidate.metadata?.name
if (!candidateName) continue
const normalizedName = this.normalizeBlockName(candidateName)
if (normalizedName === normalizedRef) {
sourceBlock = candidate
break
}
}
}
}
if (!sourceBlock) {
@@ -2035,4 +2025,21 @@ export class InputResolver {
getContainingParallelId(blockId: string): string | undefined {
return this.parallelsByBlockId.get(blockId)
}
private getAccessiblePrefixes(block: SerializedBlock): Set<string> {
const prefixes = new Set<string>()
const accessibleBlocks = this.getAccessibleBlocks(block.id)
accessibleBlocks.forEach((blockId) => {
prefixes.add(normalizeBlockName(blockId))
const sourceBlock = this.blockById.get(blockId)
if (sourceBlock?.metadata?.name) {
prefixes.add(normalizeBlockName(sourceBlock.metadata.name))
}
})
SYSTEM_REFERENCE_PREFIXES.forEach((prefix) => prefixes.add(prefix))
return prefixes
}
}
+73
View File
@@ -0,0 +1,73 @@
import { normalizeBlockName } from '@/stores/workflows/utils'
export const SYSTEM_REFERENCE_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
const INVALID_REFERENCE_CHARS = /[+*/=<>!]/
export function isLikelyReferenceSegment(segment: string): boolean {
if (!segment.startsWith('<') || !segment.endsWith('>')) {
return false
}
const inner = segment.slice(1, -1)
if (inner.startsWith(' ')) {
return false
}
if (inner.match(/^\s*[<>=!]+\s*$/) || inner.match(/\s[<>=!]+\s/)) {
return false
}
if (inner.match(/^[<>=!]+\s/)) {
return false
}
if (inner.includes('.')) {
const dotIndex = inner.indexOf('.')
const beforeDot = inner.substring(0, dotIndex)
const afterDot = inner.substring(dotIndex + 1)
if (afterDot.includes(' ')) {
return false
}
if (INVALID_REFERENCE_CHARS.test(beforeDot) || INVALID_REFERENCE_CHARS.test(afterDot)) {
return false
}
} else if (INVALID_REFERENCE_CHARS.test(inner) || inner.match(/^\d/) || inner.match(/\s\d/)) {
return false
}
return true
}
export function extractReferencePrefixes(value: string): Array<{ raw: string; prefix: string }> {
if (!value || typeof value !== 'string') {
return []
}
const matches = value.match(/<[^>]+>/g)
if (!matches) {
return []
}
const references: Array<{ raw: string; prefix: string }> = []
for (const match of matches) {
if (!isLikelyReferenceSegment(match)) {
continue
}
const inner = match.slice(1, -1)
const [rawPrefix] = inner.split('.')
if (!rawPrefix) {
continue
}
const normalized = normalizeBlockName(rawPrefix)
references.push({ raw: match, prefix: normalized })
}
return references
}
+69 -7
View File
@@ -1,4 +1,5 @@
import type { Edge } from 'reactflow'
import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { createLogger } from '@/lib/logs/console/logger'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
@@ -44,22 +45,36 @@ export class Serializer {
parallels?: Record<string, Parallel>,
validateRequired = false
): SerializedWorkflow {
// Validate subflow requirements (loops/parallels) before serialization if requested
const safeLoops = loops || {}
const safeParallels = parallels || {}
const accessibleBlocksMap = this.computeAccessibleBlockIds(
blocks,
edges,
safeLoops,
safeParallels
)
if (validateRequired) {
this.validateSubflowsBeforeExecution(blocks, loops || {}, parallels || {})
this.validateSubflowsBeforeExecution(blocks, safeLoops, safeParallels)
}
return {
version: '1.0',
blocks: Object.values(blocks).map((block) => this.serializeBlock(block, validateRequired)),
blocks: Object.values(blocks).map((block) =>
this.serializeBlock(block, {
validateRequired,
allBlocks: blocks,
accessibleBlocksMap,
})
),
connections: edges.map((edge) => ({
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || undefined,
targetHandle: edge.targetHandle || undefined,
})),
loops,
parallels,
loops: safeLoops,
parallels: safeParallels,
}
}
@@ -156,7 +171,14 @@ export class Serializer {
})
}
private serializeBlock(block: BlockState, validateRequired = false): SerializedBlock {
private serializeBlock(
block: BlockState,
options: {
validateRequired: boolean
allBlocks: Record<string, BlockState>
accessibleBlocksMap: Map<string, Set<string>>
}
): SerializedBlock {
// Special handling for subflow blocks (loops, parallels, etc.)
if (block.type === 'loop' || block.type === 'parallel') {
return {
@@ -197,7 +219,7 @@ export class Serializer {
}
// Validate required fields that only users can provide (before execution starts)
if (validateRequired) {
if (options.validateRequired) {
this.validateRequiredFieldsBeforeExecution(block, blockConfig, params)
}
@@ -541,6 +563,46 @@ export class Serializer {
}
}
private computeAccessibleBlockIds(
blocks: Record<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop>,
parallels: Record<string, Parallel>
): Map<string, Set<string>> {
const accessibleMap = new Map<string, Set<string>>()
const simplifiedEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
Object.keys(blocks).forEach((blockId) => {
const ancestorIds = BlockPathCalculator.findAllPathNodes(simplifiedEdges, blockId)
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
if (starterBlock) {
accessibleIds.add(starterBlock.id)
}
Object.values(loops).forEach((loop) => {
if (!loop?.nodes) return
if (loop.nodes.includes(blockId)) {
loop.nodes.forEach((nodeId) => accessibleIds.add(nodeId))
}
})
Object.values(parallels).forEach((parallel) => {
if (!parallel?.nodes) return
if (parallel.nodes.includes(blockId)) {
parallel.nodes.forEach((nodeId) => accessibleIds.add(nodeId))
}
})
accessibleMap.set(blockId, accessibleIds)
})
return accessibleMap
}
deserializeWorkflow(workflow: SerializedWorkflow): {
blocks: Record<string, BlockState>
edges: Edge[]