diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/long-input.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/long-input.tsx index d7a9c98c26..27d4ead57a 100644 --- a/app/w/[id]/components/workflow-block/components/sub-block/components/long-input.tsx +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/long-input.tsx @@ -5,6 +5,8 @@ import { useState, useRef } from 'react' import { SubBlockConfig } from '@/blocks/types' import { formatDisplayText } from '@/components/ui/formatted-text' import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-dropdown' +import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown' +import { useWorkflowStore } from '@/stores/workflow/store' interface LongInputProps { placeholder?: string @@ -23,6 +25,7 @@ export function LongInput({ }: LongInputProps) { const [value, setValue] = useSubBlockValue(blockId, subBlockId) const [showEnvVars, setShowEnvVars] = useState(false) + const [showTags, setShowTags] = useState(false) const [searchTerm, setSearchTerm] = useState('') const [cursorPosition, setCursorPosition] = useState(0) const textareaRef = useRef(null) @@ -34,9 +37,15 @@ export function LongInput({ const newCursorPosition = e.target.selectionStart ?? 0 setValue(newValue) setCursorPosition(newCursorPosition) - const { show, searchTerm } = checkEnvVarTrigger(newValue, newCursorPosition) - setShowEnvVars(show) - setSearchTerm(searchTerm) + + // Check for environment variables trigger + const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '') + + // Check for tag trigger + const tagTrigger = checkTagTrigger(newValue, newCursorPosition) + setShowTags(tagTrigger.show) } // Sync scroll position between textarea and overlay @@ -48,41 +57,53 @@ export function LongInput({ } // Drag and Drop handlers - const handleDrop = (e: React.DragEvent) => { + const handleDragOver = (e: React.DragEvent) => { + if (config?.connectionDroppable === false) return e.preventDefault() + } + + const handleDrop = (e: React.DragEvent) => { + if (config?.connectionDroppable === false) return + e.preventDefault() + try { const data = JSON.parse(e.dataTransfer.getData('application/json')) + if (data.type !== 'connectionBlock') return - const isValidConnectionBlock = - data.type === 'connectionBlock' && - data.connectionData.sourceBlockId === blockId - - if (!isValidConnectionBlock) return - + // Get current cursor position or append to end + const dropPosition = textareaRef.current?.selectionStart ?? value?.toString().length ?? 0 + + // Insert '<' at drop position to trigger the dropdown const currentValue = value?.toString() ?? '' - const connectionName = data.connectionData.name - .replace(/\s+/g, '') - .toLowerCase() - const outputSuffix = - data.connectionData.outputType === 'any' - ? 'res' - : data.connectionData.outputType + const newValue = currentValue.slice(0, dropPosition) + '<' + currentValue.slice(dropPosition) + + // Focus the textarea first + textareaRef.current?.focus() - const newValue = `${currentValue}<${connectionName}.${outputSuffix}>` - setValue(newValue) + // Update all state in a single batch + Promise.resolve().then(() => { + setValue(newValue) + setCursorPosition(dropPosition + 1) + setShowTags(true) + + // Set cursor position after state updates + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.selectionStart = dropPosition + 1 + textareaRef.current.selectionEnd = dropPosition + 1 + } + }, 0) + }) } catch (error) { console.error('Failed to parse drop data:', error) } } - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - } - // Handle key combinations const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setShowEnvVars(false) + setShowTags(false) } } @@ -106,6 +127,7 @@ export function LongInput({ onKeyDown={handleKeyDown} onFocus={() => { setShowEnvVars(false) + setShowTags(false) setSearchTerm('') }} /> @@ -126,6 +148,16 @@ export function LongInput({ setSearchTerm('') }} /> + { + setShowTags(false) + }} + /> ) } diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx index f3f169a975..a210378065 100644 --- a/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils' import { SubBlockConfig } from '@/blocks/types' import { formatDisplayText } from '@/components/ui/formatted-text' import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-dropdown' +import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown' interface ShortInputProps { placeholder?: string @@ -25,6 +26,7 @@ export function ShortInput({ }: ShortInputProps) { const [isFocused, setIsFocused] = useState(false) const [showEnvVars, setShowEnvVars] = useState(false) + const [showTags, setShowTags] = useState(false) const [value, setValue] = useSubBlockValue(blockId, subBlockId) const [searchTerm, setSearchTerm] = useState('') const [cursorPosition, setCursorPosition] = useState(0) @@ -37,9 +39,15 @@ export function ShortInput({ const newCursorPosition = e.target.selectionStart ?? 0 setValue(newValue) setCursorPosition(newCursorPosition) - const { show, searchTerm } = checkEnvVarTrigger(newValue, newCursorPosition) - setShowEnvVars(show) - setSearchTerm(searchTerm) + + // Check for environment variables trigger + const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '') + + // Check for tag trigger + const tagTrigger = checkTagTrigger(newValue, newCursorPosition) + setShowTags(tagTrigger.show) } // Sync scroll position between input and overlay @@ -63,41 +71,53 @@ export function ShortInput({ }, [value, isFocused]) // Drag and Drop handlers - const handleDrop = (e: React.DragEvent) => { + const handleDragOver = (e: React.DragEvent) => { + if (config?.connectionDroppable === false) return e.preventDefault() + } + + const handleDrop = (e: React.DragEvent) => { + if (config?.connectionDroppable === false) return + e.preventDefault() + try { const data = JSON.parse(e.dataTransfer.getData('application/json')) + if (data.type !== 'connectionBlock') return - const isValidConnectionBlock = - data.type === 'connectionBlock' && - data.connectionData.sourceBlockId === blockId - - if (!isValidConnectionBlock) return - + // Get current cursor position or append to end + const dropPosition = inputRef.current?.selectionStart ?? value?.toString().length ?? 0 + + // Insert '<' at drop position to trigger the dropdown const currentValue = value?.toString() ?? '' - const connectionName = data.connectionData.name - .replace(/\s+/g, '') - .toLowerCase() - const outputSuffix = - data.connectionData.outputType === 'any' - ? 'res' - : data.connectionData.outputType + const newValue = currentValue.slice(0, dropPosition) + '<' + currentValue.slice(dropPosition) + + // Focus the input first + inputRef.current?.focus() - const newValue = `${currentValue}<${connectionName}.${outputSuffix}>` - setValue(newValue) + // Update all state in a single batch + Promise.resolve().then(() => { + setValue(newValue) + setCursorPosition(dropPosition + 1) + setShowTags(true) + + // Set cursor position after state updates + setTimeout(() => { + if (inputRef.current) { + inputRef.current.selectionStart = dropPosition + 1 + inputRef.current.selectionEnd = dropPosition + 1 + } + }, 0) + }) } catch (error) { console.error('Failed to parse drop data:', error) } } - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - } - // Handle key combinations const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setShowEnvVars(false) + setShowTags(false) } } @@ -124,6 +144,7 @@ export function ShortInput({ onFocus={() => { setIsFocused(true) setShowEnvVars(false) + setShowTags(false) setSearchTerm('') }} onBlur={() => setIsFocused(false)} @@ -152,6 +173,16 @@ export function ShortInput({ setSearchTerm('') }} /> + { + setShowTags(false) + }} + /> ) } diff --git a/app/w/hooks/use-block-connections.ts b/app/w/hooks/use-block-connections.ts index 6e18cf7f3c..a1c2ae6f1f 100644 --- a/app/w/hooks/use-block-connections.ts +++ b/app/w/hooks/use-block-connections.ts @@ -24,7 +24,7 @@ export function useBlockConnections(blockId: string) { return { id: sourceBlock.id, type: sourceBlock.type, - outputType: 'response', + outputType: Object.keys(sourceBlock.outputs || {}), name: sourceBlock.name, } }) diff --git a/blocks/types.ts b/blocks/types.ts index 890213bb46..0a111ee325 100644 --- a/blocks/types.ts +++ b/blocks/types.ts @@ -9,9 +9,9 @@ export type BlockCategory = 'basic' | 'advanced' export type PrimitiveValueType = 'string' | 'number' | 'json' | 'boolean' | 'any' export type ValueType = PrimitiveValueType | Record -export interface BlockOutput { - response: ValueType -} +export type BlockOutput = + | PrimitiveValueType + | { [key: string]: BlockOutput } export type ParamType = 'string' | 'number' | 'boolean' | 'json' diff --git a/blocks/utils.ts b/blocks/utils.ts index 18105e7d71..5f86799541 100644 --- a/blocks/utils.ts +++ b/blocks/utils.ts @@ -50,7 +50,7 @@ export function resolveOutputType( for (const [key, outputConfig] of Object.entries(outputs)) { // If no dependencies, use the type directly if (!outputConfig.dependsOn) { - resolvedOutputs[key] = { response: outputConfig.type } + resolvedOutputs[key] = outputConfig.type continue } diff --git a/components/ui/tag-dropdown.tsx b/components/ui/tag-dropdown.tsx new file mode 100644 index 0000000000..0817a1acc7 --- /dev/null +++ b/components/ui/tag-dropdown.tsx @@ -0,0 +1,167 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { cn } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflow/store' + +interface TagDropdownProps { + visible: boolean + onSelect: (newValue: string) => void + blockId: string + className?: string + inputValue: string + cursorPosition: number + onClose?: () => void +} + +export const TagDropdown: React.FC = ({ + visible, + onSelect, + blockId, + className, + inputValue, + cursorPosition, + onClose, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + // Get available tags from workflow state + const blocks = useWorkflowStore((state) => state.blocks) + const edges = useWorkflowStore((state) => state.edges) + + // Get source block and compute tags + const { tags } = useMemo(() => { + const sourceEdge = edges.find(edge => edge.target === blockId) + const sourceBlock = sourceEdge ? blocks[sourceEdge.source] : null + + if (!sourceBlock) { + return { tags: [] } + } + + // Get all available output paths recursively + const getOutputPaths = (obj: any, prefix = ''): string[] => { + // If we're at a primitive type or null, return the current path + if (typeof obj !== 'object' || obj === null) { + return prefix ? [prefix] : [] + } + + // If we have a type field, this is a block output definition + if ('type' in obj) { + return getOutputPaths(obj.type, prefix) + } + + // Otherwise, traverse the object's properties + return Object.entries(obj).flatMap(([key, value]) => { + const newPrefix = prefix ? `${prefix}.${key}` : key + return getOutputPaths(value, newPrefix) + }) + } + + // Get all output paths starting from the outputs object + const outputPaths = getOutputPaths(sourceBlock.outputs) + const blockName = sourceBlock.name || sourceBlock.type + + // Format tags with block name and output paths + return { + tags: outputPaths.map(path => `${blockName.replace(/\s+/g, '').toLowerCase()}.${path}`) + } + }, [blocks, edges, blockId]) + + // Handle tag selection + const handleTagSelect = (tag: string) => { + const textBeforeCursor = inputValue.slice(0, cursorPosition) + const textAfterCursor = inputValue.slice(cursorPosition) + + // Find the position of the last '<' before cursor + const lastOpenBracket = textBeforeCursor.lastIndexOf('<') + if (lastOpenBracket === -1) return + + const newValue = textBeforeCursor.slice(0, lastOpenBracket) + + '<' + tag + '>' + + textAfterCursor + + onSelect(newValue) + onClose?.() + } + + // Handle keyboard navigation + const handleKeyDown = (e: KeyboardEvent) => { + if (!visible || tags.length === 0) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => + prev < tags.length - 1 ? prev + 1 : prev + ) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => prev > 0 ? prev - 1 : prev) + break + case 'Enter': + e.preventDefault() + handleTagSelect(tags[selectedIndex]) + break + case 'Escape': + e.preventDefault() + onClose?.() + break + } + } + + // Add and remove keyboard event listener + useEffect(() => { + if (visible) { + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + } + }, [visible, selectedIndex, tags]) + + // Don't render if not visible or no tags + if (!visible || tags.length === 0) return null + + return ( +
+
+ {tags.map((tag, index) => ( + + ))} +
+
+ ) +} + +// Helper function to check for '<' trigger +export const checkTagTrigger = (text: string, cursorPosition: number): { show: boolean } => { + if (cursorPosition >= 1) { + const textBeforeCursor = text.slice(0, cursorPosition) + const lastOpenBracket = textBeforeCursor.lastIndexOf('<') + const lastCloseBracket = textBeforeCursor.lastIndexOf('>') + + // Show if we have an unclosed '<' that's not part of a completed tag + if (lastOpenBracket !== -1 && (lastCloseBracket === -1 || lastCloseBracket < lastOpenBracket)) { + return { show: true } + } + } + return { show: false } +} + diff --git a/executor/index.ts b/executor/index.ts index 3bec42bab9..f41827706b 100644 --- a/executor/index.ts +++ b/executor/index.ts @@ -141,7 +141,7 @@ export class Executor { // Update block log with success blockLog.success = true - blockLog.output = output.response + blockLog.output = output return output } catch (error) { // Update block log with error