Added envvar store, resolves values in short-input, long-input, code sub-blocks. Resolves value at execution time

This commit is contained in:
Waleed Latif
2025-02-01 13:15:11 -08:00
parent 78fa6b44d3
commit 4e44594953
10 changed files with 331 additions and 144 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
import { formatDisplayText } from '@/components/ui/formatted-text'
interface CodeLine {
id: string
@@ -714,6 +715,9 @@ export function Code({ blockId, subBlockId, isConnecting }: CodeProps) {
wrap="off"
onPaste={handlePaste}
/>
<div className="absolute inset-0 pointer-events-none px-3 py-1 whitespace-pre text-sm">
{formatDisplayText(line.content)}
</div>
</div>
))}
</div>

View File

@@ -3,6 +3,7 @@ import { useSubBlockValue } from '../hooks/use-sub-block-value'
import { cn } from '@/lib/utils'
import { useState, useRef, useEffect } from 'react'
import { SubBlockConfig } from '@/blocks/types'
import { formatDisplayText } from '@/components/ui/formatted-text'
interface LongInputProps {
placeholder?: string
@@ -63,25 +64,6 @@ export function LongInput({
e.preventDefault()
}
const formatDisplayText = (text: string) => {
if (!text) return null
// Split the text by tag pattern <something.something>
const parts = text.split(/(<[^>]+>)/g)
return parts.map((part, index) => {
// Check if the part matches tag pattern
if (part.match(/^<[^>]+>$/)) {
return (
<span key={index} className="text-blue-500">
{part}
</span>
)
}
return <span key={index}>{part}</span>
})
}
return (
<div className="relative w-full">
<Textarea

View File

@@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from 'react'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
import { cn } from '@/lib/utils'
import { SubBlockConfig } from '@/blocks/types'
import { formatDisplayText } from '@/components/ui/formatted-text'
interface ShortInputProps {
placeholder?: string
@@ -84,25 +85,6 @@ export function ShortInput({
? '•'.repeat(value?.toString().length ?? 0)
: value?.toString() ?? ''
const formatDisplayText = (text: string) => {
if (!text) return null
// Split the text by tag pattern <something.something>
const parts = text.split(/(<[^>]+>)/g)
return parts.map((part, index) => {
// Check if the part matches tag pattern
if (part.match(/^<[^>]+>$/)) {
return (
<span key={index} className="text-blue-500">
{part}
</span>
)
}
return <span key={index}>{part}</span>
})
}
return (
<div className="relative w-full">
<Input

View File

@@ -6,14 +6,26 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useEnvironmentStore } from '@/stores/environment/store'
import { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/environment/types'
interface EnvVar {
key: string
value: string
// Extend the store type with our UI-specific fields
interface UIEnvironmentVariable extends StoreEnvironmentVariable {
id?: number
}
interface SettingsModalProps {
@@ -22,52 +34,95 @@ interface SettingsModalProps {
}
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),40px] gap-4'
const INITIAL_ENV_VAR: EnvVar = { key: '', value: '' }
const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' }
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [envVars, setEnvVars] = useState<EnvVar[]>([INITIAL_ENV_VAR])
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(
null
)
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
const { variables, setVariable, removeVariable } = useEnvironmentStore()
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingClose = useRef(false)
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
// Check if there are unsaved changes by comparing with initial state
const hasChanges = useMemo(() => {
// Filter out empty rows from both initial and current state
const initialVars = initialVarsRef.current.filter(v => v.key || v.value)
const currentVars = envVars.filter(v => v.key || v.value)
// Create maps for easier comparison
const initialMap = new Map(initialVars.map(v => [v.key, v.value]))
const currentMap = new Map(currentVars.map(v => [v.key, v.value]))
// Different number of non-empty variables
if (initialMap.size !== currentMap.size) return true
// Check for any differences in keys or values
for (const [key, value] of currentMap) {
const initialValue = initialMap.get(key)
// If key doesn't exist in initial or value is different
if (initialValue !== value) {
return true
}
}
// Check if any initial keys are missing from current
for (const key of initialMap.keys()) {
if (!currentMap.has(key)) {
return true
}
}
return false
}, [envVars])
// Reset state when modal is opened
useEffect(() => {
inputRefs.current = inputRefs.current.slice(0, envVars.length)
}, [envVars.length])
if (open) {
const existingVars = Object.values(variables)
const initialVars = existingVars.length ? existingVars : [INITIAL_ENV_VAR]
// Create deep copy of initial vars to prevent reference issues
initialVarsRef.current = JSON.parse(JSON.stringify(initialVars))
setEnvVars(JSON.parse(JSON.stringify(initialVars)))
pendingClose.current = false
}
}, [open, variables])
const handleClose = () => {
if (hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(false)
}
}
const handleCancel = () => {
// Reset to initial state when cancelling (without saving)
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
setShowUnsavedChanges(false)
if (pendingClose.current) {
onOpenChange(false)
}
}
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight
}
}, [envVars])
const setInputRef = (el: HTMLInputElement | null, index: number) => {
inputRefs.current[index] = el
}
const handleValueFocus = (index: number) => {
const handleValueFocus = (index: number, e: React.FocusEvent<HTMLInputElement>) => {
setFocusedValueIndex(index)
setTimeout(() => {
const input = inputRefs.current[index]
if (input) {
input.setSelectionRange(0, 0)
input.scrollLeft = 0
}
}, 0)
// Always scroll to the start of the input
e.target.scrollLeft = 0
}
const handleValueClick = (
e: React.MouseEvent<HTMLInputElement>,
index: number
) => {
const handleValueClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault()
const input = inputRefs.current[index]
if (input) {
input.setSelectionRange(0, 0)
input.scrollLeft = 0
}
// Always scroll to the start of the input
e.currentTarget.scrollLeft = 0
}
const handleValueKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -110,9 +165,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}
const addEnvVar = () => setEnvVars([...envVars, INITIAL_ENV_VAR])
const addEnvVar = () => {
// Create a fresh empty variable with a unique id
const newVar = { key: '', value: '', id: Date.now() }
setEnvVars([...envVars, newVar])
}
const updateEnvVar = (index: number, field: keyof EnvVar, value: string) => {
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
@@ -123,8 +182,26 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR])
}
const renderEnvVarRow = (envVar: EnvVar, index: number) => (
<div key={index} className={`${GRID_COLS} items-center`}>
const handleSave = () => {
// Save all valid environment variables to the store
const validVars = envVars.filter(v => v.key && v.value)
validVars.forEach(v => setVariable(v.key, v.value))
// Remove any variables that were deleted
const currentKeys = new Set(validVars.map(v => v.key))
Object.keys(variables).forEach(key => {
if (!currentKeys.has(key)) {
removeVariable(key)
}
})
if (pendingClose.current) {
onOpenChange(false)
}
}
const renderEnvVarRow = (envVar: UIEnvironmentVariable, index: number) => (
<div key={envVar.id || index} className={`${GRID_COLS} items-center`}>
<Input
value={envVar.key}
onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
@@ -132,15 +209,15 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
placeholder="e.g. API_KEY"
/>
<Input
ref={(el) => setInputRef(el, index)}
value={envVar.value}
onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
type={focusedValueIndex === index ? 'text' : 'password'}
onFocus={() => handleValueFocus(index)}
onClick={(e) => handleValueClick(e, index)}
onFocus={(e) => handleValueFocus(index, e)}
onClick={handleValueClick}
onBlur={() => setFocusedValueIndex(null)}
onKeyDown={handleValueKeyDown}
onPaste={(e) => handlePaste(e, index)}
placeholder="Enter value"
className="allow-scroll"
/>
<Button
variant="ghost"
@@ -154,44 +231,70 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Environment Variables</DialogTitle>
</DialogHeader>
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Environment Variables</DialogTitle>
</DialogHeader>
<div className="flex flex-col flex-1 min-h-0">
<div className="space-y-1.5">
<div className={`${GRID_COLS} px-0.5`}>
<Label>Key</Label>
<Label>Value</Label>
<div />
<div className="flex flex-col flex-1 min-h-0">
<div className="space-y-1.5">
<div className={`${GRID_COLS} px-0.5`}>
<Label>Key</Label>
<Label>Value</Label>
<div />
</div>
<div className="relative">
<div
ref={scrollContainerRef}
className="overflow-y-auto max-h-[40vh] space-y-2 scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent pr-6 -mr-6 pb-2 pt-2 px-2 -mx-2"
>
{envVars.map(renderEnvVarRow)}
</div>
</div>
</div>
<div className="relative">
<div
ref={scrollContainerRef}
className="overflow-y-auto max-h-[40vh] space-y-2 scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent pr-6 -mr-6 pb-2 pt-2 px-2 -mx-2"
>
{envVars.map(renderEnvVarRow)}
<div className="flex flex-col gap-4 pt-4 border-t mt-4">
<Button variant="outline" size="sm" onClick={addEnvVar}>
Add Variable
</Button>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges}
>
Save Changes
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<div className="flex flex-col gap-4 pt-4 border-t mt-4">
<Button variant="outline" size="sm" onClick={addEnvVar}>
Add Variable
</Button>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onOpenChange(false)}>Save Changes</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Do you want to save them before closing?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>
Discard Changes
</AlertDialogCancel>
<AlertDialogAction onClick={handleSave}>
Save Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -6,6 +6,7 @@ import { ExecutionResult } from '@/executor/types'
import { useNotificationStore } from '@/stores/notifications/store'
import { useWorkflowRegistry } from '@/stores/workflow/registry'
import { useConsoleStore } from '@/stores/console/store'
import { useEnvironmentStore } from '@/stores/environment/store'
export function useWorkflowExecution() {
const [isExecuting, setIsExecuting] = useState(false)
@@ -14,6 +15,7 @@ export function useWorkflowExecution() {
const { activeWorkflowId } = useWorkflowRegistry()
const { addNotification } = useNotificationStore()
const { addConsole, toggleConsole, isOpen } = useConsoleStore()
const { getAllVariables } = useEnvironmentStore()
const handleRunWorkflow = useCallback(async () => {
if (!activeWorkflowId) return
@@ -34,9 +36,16 @@ export function useWorkflowExecution() {
return acc
}, {} as Record<string, any>)
// Get environment variables
const envVars = getAllVariables()
const envVarValues = Object.entries(envVars).reduce((acc, [key, variable]) => {
acc[key] = variable.value
return acc
}, {} as Record<string, string>)
// Execute workflow
const workflow = new Serializer().serializeWorkflow(blocks, edges)
const executor = new Executor(workflow, currentBlockStates)
const executor = new Executor(workflow, currentBlockStates, envVarValues)
const result = await executor.execute('my-run-id')
setExecutionResult(result)
@@ -85,7 +94,8 @@ export function useWorkflowExecution() {
setExecutionResult({
success: false,
output: { response: {} },
error: errorMessage
error: errorMessage,
logs: []
})
// Add error entry to console
@@ -104,7 +114,7 @@ export function useWorkflowExecution() {
} finally {
setIsExecuting(false)
}
}, [activeWorkflowId, blocks, edges, addNotification, addConsole, isOpen, toggleConsole])
}, [activeWorkflowId, blocks, edges, addNotification, addConsole, isOpen, toggleConsole, getAllVariables])
return { isExecuting, executionResult, handleRunWorkflow }
}

View File

@@ -0,0 +1,34 @@
'use client'
import { ReactNode } from 'react'
/**
* Formats text by highlighting block references (<...>) and environment variables ({...})
* Used in code editor, long inputs, and short inputs for consistent syntax highlighting
*/
export function formatDisplayText(text: string | null): ReactNode {
if (!text) return null
// Split the text by both tag patterns <something.something> and {ENV_VAR}
const parts = text.split(/(<[^>]+>|\{[^}]+\})/g)
return parts.map((part, index) => {
// Check if the part matches connection tag pattern
if (part.match(/^<[^>]+>$/)) {
return (
<span key={index} className="text-blue-500">
{part}
</span>
)
}
// Check if the part matches environment variable pattern
if (part.match(/^\{[^}]+\}$/)) {
return (
<span key={index} className="text-blue-500">
{part}
</span>
)
}
return <span key={index}>{part}</span>
})
}

View File

@@ -23,7 +23,8 @@ export class Executor {
constructor(
private workflow: SerializedWorkflow,
// Initial block states can be passed in if you need to resume workflows or pre-populate data.
private initialBlockStates: Record<string, BlockOutput> = {}
private initialBlockStates: Record<string, BlockOutput> = {},
private environmentVariables: Record<string, string> = {}
) {}
/**
@@ -40,6 +41,7 @@ export class Executor {
metadata: {
startTime: startTime.toISOString()
},
environmentVariables: this.environmentVariables
}
// Pre-populate block states if initialBlockStates exist
@@ -314,10 +316,12 @@ export class Executor {
const resolvedInputs = Object.entries(inputs).reduce(
(acc, [key, value]) => {
if (typeof value === 'string') {
const matches = value.match(/<([^>]+)>/g)
if (matches) {
let resolvedValue = value
for (const match of matches) {
let resolvedValue = value
// Handle block references with <> syntax
const blockMatches = value.match(/<([^>]+)>/g)
if (blockMatches) {
for (const match of blockMatches) {
// e.g. "<someBlockId.response>"
const path = match.slice(1, -1) // remove < and >
const [blockRef, ...pathParts] = path.split('.')
@@ -365,20 +369,32 @@ export class Executor {
throw new Error(`No value found at path "${path}" in block "${sourceBlock.metadata?.title}".`)
}
}
}
// Attempt JSON parse if it looks like JSON
try {
if (resolvedValue.startsWith('{') || resolvedValue.startsWith('[')) {
acc[key] = JSON.parse(resolvedValue)
} else {
acc[key] = resolvedValue
// Handle environment variables with {} syntax
const envMatches = resolvedValue.match(/\{([^}]+)\}/g)
if (envMatches) {
for (const match of envMatches) {
const envKey = match.slice(1, -1) // remove { and }
const envValue = context.environmentVariables?.[envKey]
if (envValue === undefined) {
throw new Error(`Environment variable "${envKey}" was not found.`)
}
} catch {
resolvedValue = resolvedValue.replace(match, envValue)
}
}
// After all replacements are done, attempt JSON parse if it looks like JSON
try {
if (resolvedValue.startsWith('{') || resolvedValue.startsWith('[')) {
acc[key] = JSON.parse(resolvedValue)
} else {
acc[key] = resolvedValue
}
} else {
// No placeholders
acc[key] = value
} catch {
acc[key] = resolvedValue
}
} else {
// Not a string param

View File

@@ -6,31 +6,30 @@ import { BlockOutput } from '@/blocks/types'
export interface BlockLog {
blockId: string
blockTitle?: string
success: boolean
error?: string
blockType?: string
startedAt: string
endedAt: string
durationMs: number
success: boolean
output?: any
blockType?: string
error?: string
}
/**
* Describes the runtime context for executing a workflow,
* including all block outputs (blockStates), metadata for timing, and block logs.
*/
export interface ExecutionMetadata {
startTime?: string
endTime?: string
}
export interface ExecutionContext {
workflowId: string
blockStates: Map<string, BlockOutput>
// Make metadata non-optional so we can assign .startTime or .endTime without TS warnings
metadata: {
startTime?: string
endTime?: string
// You can keep an index signature if you want to store extra fields
[key: string]: any
}
// We store logs in an array so the final result includes a step-by-step record
blockLogs: BlockLog[]
metadata: ExecutionMetadata
environmentVariables?: Record<string, string>
}
/**
@@ -41,13 +40,12 @@ export interface ExecutionResult {
success: boolean
output: BlockOutput
error?: string
logs?: BlockLog[]
metadata?: {
duration: number
startTime: string
endTime: string
}
// Detailed logs of what happened in each block
logs?: BlockLog[]
}
/**

View File

@@ -0,0 +1,42 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { EnvironmentStore, EnvironmentVariable } from './types'
export const useEnvironmentStore = create<EnvironmentStore>()(
persist(
(set, get) => ({
variables: {},
setVariable: (key: string, value: string) => {
set((state: EnvironmentStore) => ({
variables: {
...state.variables,
[key]: { key, value }
}
}))
},
removeVariable: (key: string) => {
set((state: EnvironmentStore) => {
const { [key]: _, ...rest } = state.variables
return { variables: rest }
})
},
clearVariables: () => {
set({ variables: {} })
},
getVariable: (key: string) => {
return get().variables[key]?.value
},
getAllVariables: () => {
return get().variables
}
}),
{
name: 'environment-store'
}
)
)

View File

@@ -0,0 +1,16 @@
export interface EnvironmentVariable {
key: string
value: string
}
export interface EnvironmentState {
variables: Record<string, EnvironmentVariable>
}
export interface EnvironmentStore extends EnvironmentState {
setVariable: (key: string, value: string) => void
removeVariable: (key: string) => void
clearVariables: () => void
getVariable: (key: string) => string | undefined
getAllVariables: () => Record<string, EnvironmentVariable>
}