mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(variables): variable experience and removed execution time (#349)
* improvement(variables): removed string type and kept plaintext without processing input * improvement(function): removed execution time * fix(variables): inputting variables and validating format * fix(variables): sync * fix(tests): upgraded tests * Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
@@ -63,11 +63,23 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
variablesRecord[variable.id] = variable
|
||||
})
|
||||
|
||||
// Get existing variables to merge with the incoming ones
|
||||
const existingVariables = (workflowRecord[0].variables as Record<string, Variable>) || {}
|
||||
|
||||
// Create a timestamp based on the current request
|
||||
|
||||
// Merge variables: Keep existing ones and update/add new ones
|
||||
// This prevents variables from being deleted during race conditions
|
||||
const mergedVariables = {
|
||||
...existingVariables,
|
||||
...variablesRecord
|
||||
}
|
||||
|
||||
// Update workflow with variables
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
variables: variablesRecord,
|
||||
variables: mergedVariables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ChevronDown, Copy, MoreVertical, Plus, Trash } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
Trash,
|
||||
} from 'lucide-react'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
@@ -51,6 +60,9 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
// Track editor references
|
||||
const editorRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
// Track which variables are currently being edited
|
||||
const [activeEditors, setActiveEditors] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Auto-save when variables are added/edited
|
||||
const handleAddVariable = () => {
|
||||
if (!activeWorkflowId) return
|
||||
@@ -68,8 +80,8 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
|
||||
const getTypeIcon = (type: VariableType) => {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return 'Aa'
|
||||
case 'plain':
|
||||
return 'Abc'
|
||||
case 'number':
|
||||
return '123'
|
||||
case 'boolean':
|
||||
@@ -78,7 +90,7 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
return '{}'
|
||||
case 'array':
|
||||
return '[]'
|
||||
case 'plain':
|
||||
case 'string':
|
||||
return 'Abc'
|
||||
default:
|
||||
return '?'
|
||||
@@ -87,8 +99,8 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
|
||||
const getPlaceholder = (type: VariableType) => {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return '"Hello world"'
|
||||
case 'plain':
|
||||
return 'Plain text value'
|
||||
case 'number':
|
||||
return '42'
|
||||
case 'boolean':
|
||||
@@ -97,7 +109,7 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
return '{\n "key": "value"\n}'
|
||||
case 'array':
|
||||
return '[\n 1,\n 2,\n 3\n]'
|
||||
case 'plain':
|
||||
case 'string':
|
||||
return 'Plain text value'
|
||||
default:
|
||||
return ''
|
||||
@@ -117,16 +129,100 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle editor value changes - store exactly what user types
|
||||
const handleEditorChange = (variable: Variable, newValue: string) => {
|
||||
// Store the raw value directly, no parsing or formatting
|
||||
updateVariable(variable.id, {
|
||||
value: newValue,
|
||||
// Clear any previous validation errors so they'll be recalculated
|
||||
validationError: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Only track focus state for UI purposes
|
||||
const handleEditorBlur = (variableId: string) => {
|
||||
setActiveEditors((prev) => ({
|
||||
...prev,
|
||||
[variableId]: false,
|
||||
}))
|
||||
}
|
||||
|
||||
// Track when editor becomes active
|
||||
const handleEditorFocus = (variableId: string) => {
|
||||
setActiveEditors((prev) => ({
|
||||
...prev,
|
||||
[variableId]: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// Always return raw value without any formatting
|
||||
const formatValue = (variable: Variable) => {
|
||||
if (variable.value === '') return ''
|
||||
|
||||
try {
|
||||
// Use the VariableManager to format values consistently
|
||||
return VariableManager.formatForEditor(variable.value, variable.type)
|
||||
} catch (e) {
|
||||
console.error('Error formatting value:', e)
|
||||
// If formatting fails, return as is
|
||||
return typeof variable.value === 'string' ? variable.value : JSON.stringify(variable.value)
|
||||
// Always return raw value exactly as typed
|
||||
return typeof variable.value === 'string' ? variable.value : JSON.stringify(variable.value)
|
||||
}
|
||||
|
||||
// Get validation status based on type and value
|
||||
const getValidationStatus = (variable: Variable): string | undefined => {
|
||||
// Empty values don't need validation
|
||||
if (variable.value === '') return undefined
|
||||
|
||||
// Otherwise validate based on type
|
||||
switch (variable.type) {
|
||||
case 'number':
|
||||
return isNaN(Number(variable.value)) ? 'Not a valid number' : undefined
|
||||
case 'boolean':
|
||||
return !/^(true|false)$/i.test(String(variable.value).trim())
|
||||
? 'Expected "true" or "false"'
|
||||
: undefined
|
||||
case 'object':
|
||||
try {
|
||||
// Handle both JavaScript and JSON syntax
|
||||
let valueToValidate = String(variable.value).trim()
|
||||
|
||||
// If it's clearly JS syntax, convert it to valid JSON
|
||||
if (valueToValidate.includes("'") || /\b\w+\s*:/.test(valueToValidate)) {
|
||||
// Replace JS single quotes with double quotes, but handle escaped quotes correctly
|
||||
valueToValidate = valueToValidate
|
||||
.replace(/(\w+)\s*:/g, '"$1":') // Convert unquoted property names to quoted
|
||||
.replace(/'/g, '"') // Replace single quotes with double quotes
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(valueToValidate)
|
||||
return !parsed || typeof parsed !== 'object' || Array.isArray(parsed)
|
||||
? 'Not a valid JSON object'
|
||||
: undefined
|
||||
} catch {
|
||||
return 'Invalid JSON object syntax'
|
||||
}
|
||||
case 'array':
|
||||
try {
|
||||
// Use actual JavaScript evaluation instead of trying to convert to JSON
|
||||
// This properly handles all valid JS array syntax including mixed types
|
||||
let valueToEvaluate = String(variable.value).trim()
|
||||
|
||||
// Basic security check to prevent arbitrary code execution
|
||||
if (!valueToEvaluate.startsWith('[') || !valueToEvaluate.endsWith(']')) {
|
||||
return 'Not a valid array format'
|
||||
}
|
||||
|
||||
// Use Function constructor to safely evaluate the array expression
|
||||
// This is safer than eval() and handles all JS array syntax correctly
|
||||
const parsed = new Function(`return ${valueToEvaluate}`)()
|
||||
|
||||
// Verify it's actually an array
|
||||
if (!Array.isArray(parsed)) {
|
||||
return 'Not a valid array'
|
||||
}
|
||||
|
||||
return undefined // Valid array
|
||||
} catch (e) {
|
||||
console.log('Array parsing error:', e)
|
||||
return 'Invalid array syntax'
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,20 +236,6 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
})
|
||||
}, [workflowVariables])
|
||||
|
||||
// Handle editor value changes
|
||||
const handleEditorChange = (variable: Variable, newValue: string) => {
|
||||
try {
|
||||
// Use the VariableManager to consistently parse input values
|
||||
const processedValue = VariableManager.parseInputForStorage(newValue, variable.type)
|
||||
|
||||
// Update the variable with the processed value
|
||||
updateVariable(variable.id, { value: processedValue })
|
||||
} catch (e) {
|
||||
// If processing fails, use the raw value
|
||||
updateVariable(variable.id, { value: newValue })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-3">
|
||||
@@ -199,11 +281,11 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-32">
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateVariable(variable.id, { type: 'string' })}
|
||||
onClick={() => updateVariable(variable.id, { type: 'plain' })}
|
||||
className="cursor-pointer flex items-center"
|
||||
>
|
||||
<div className="w-5 text-center mr-2 font-mono text-sm">Aa</div>
|
||||
<span>String</span>
|
||||
<div className="w-5 text-center mr-2 font-mono text-sm">Abc</div>
|
||||
<span>Plain</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateVariable(variable.id, { type: 'number' })}
|
||||
@@ -233,13 +315,6 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
<div className="w-5 text-center mr-2 font-mono text-sm">[]</div>
|
||||
<span>Array</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateVariable(variable.id, { type: 'plain' })}
|
||||
className="cursor-pointer flex items-center"
|
||||
>
|
||||
<div className="w-5 text-center mr-2 font-mono text-sm">Abc</div>
|
||||
<span>Plain</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -281,6 +356,10 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
ref={(el) => {
|
||||
editorRefs.current[variable.id] = el
|
||||
}}
|
||||
style={{
|
||||
maxWidth: panelWidth ? `${panelWidth - 50}px` : '100%',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{variable.value === '' && (
|
||||
<div className="absolute top-[8.5px] left-4 text-muted-foreground/50 pointer-events-none select-none">
|
||||
@@ -291,6 +370,8 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
key={`editor-${variable.id}-${variable.type}`}
|
||||
value={formatValue(variable)}
|
||||
onValueChange={handleEditorChange.bind(null, variable)}
|
||||
onBlur={() => handleEditorBlur(variable.id)}
|
||||
onFocus={() => handleEditorFocus(variable.id)}
|
||||
highlight={(code) =>
|
||||
highlight(
|
||||
code,
|
||||
@@ -302,10 +383,31 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: '21px',
|
||||
width: '100%',
|
||||
wordWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
className="focus:outline-none w-full"
|
||||
textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap"
|
||||
textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full whitespace-pre-wrap break-words overflow-visible"
|
||||
/>
|
||||
|
||||
{/* Show validation indicator for any non-empty variable */}
|
||||
{variable.value !== '' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="absolute top-[4px] right-[0px] cursor-help group">
|
||||
{getValidationStatus(variable) && (
|
||||
<div className="p-1 rounded-md group-hover:bg-muted/80 group-hover:shadow-sm transition-all duration-200 border border-transparent group-hover:border-muted/50">
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground opacity-30 group-hover:opacity-100 transition-opacity duration-200" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{getValidationStatus(variable) && <p>{getValidationStatus(variable)}</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -30,7 +30,6 @@ export const FunctionBlock: BlockConfig<CodeExecutionOutput> = {
|
||||
type: {
|
||||
result: 'any',
|
||||
stdout: 'string',
|
||||
executionTime: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1284,7 +1284,6 @@ export class Executor {
|
||||
response: {
|
||||
result: output?.result,
|
||||
stdout: output?.stdout || '',
|
||||
executionTime: output?.executionTime || 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,7 +599,7 @@ describe('InputResolver', () => {
|
||||
const result = resolver.resolveInputs(block, mockContext)
|
||||
|
||||
// String should be quoted in code context
|
||||
expect(result.code).toContain('const name = "\"Hello\"";')
|
||||
expect(result.code).toContain('const name = "Hello";')
|
||||
// Number should not be quoted
|
||||
expect(result.code).toContain('const num = 42;')
|
||||
})
|
||||
|
||||
@@ -242,8 +242,11 @@ export class InputResolver {
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle 'string' type the same as 'plain' for backward compatibility
|
||||
const type = variable.type === 'string' ? 'plain' : variable.type
|
||||
|
||||
// Use the centralized VariableManager to resolve variable values
|
||||
return VariableManager.resolveForExecution(variable.value, variable.type)
|
||||
return VariableManager.resolveForExecution(variable.value, type)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing variable ${variable.name} (type: ${variable.type}):`, error)
|
||||
return variable.value // Fallback to original value on error
|
||||
@@ -266,14 +269,23 @@ export class InputResolver {
|
||||
currentBlock?: SerializedBlock
|
||||
): string {
|
||||
try {
|
||||
// Handle 'string' type the same as 'plain' for backward compatibility
|
||||
const normalizedType = type === 'string' ? 'plain' : type
|
||||
|
||||
// For plain text, use exactly what's entered without modifications
|
||||
if (normalizedType === 'plain' && typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
// Determine if this needs special handling for code contexts
|
||||
const needsCodeStringLiteral = this.needsCodeStringLiteral(currentBlock, String(value))
|
||||
const isFunctionBlock = currentBlock?.metadata?.id === 'function'
|
||||
|
||||
// Use the appropriate formatting method based on context
|
||||
if (needsCodeStringLiteral) {
|
||||
return VariableManager.formatForCodeContext(value, type as any)
|
||||
// Always use code formatting for function blocks
|
||||
if (isFunctionBlock || needsCodeStringLiteral) {
|
||||
return VariableManager.formatForCodeContext(value, normalizedType as any)
|
||||
} else {
|
||||
return VariableManager.formatForTemplateInterpolation(value, type as any)
|
||||
return VariableManager.formatForTemplateInterpolation(value, normalizedType as any)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error formatting value for interpolation (type: ${type}):`, error)
|
||||
@@ -1065,6 +1077,11 @@ export class InputResolver {
|
||||
|
||||
// Check if this is a block that executes code
|
||||
if (block.metadata?.id && codeExecutionBlocks.includes(block.metadata.id)) {
|
||||
// Always return true for function blocks - they need properly formatted string literals
|
||||
if (block.metadata.id === 'function') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Specifically for condition blocks, stringifyForCondition handles quoting
|
||||
// so we don't need extra quoting here unless it's within an expression.
|
||||
if (block.metadata.id === 'condition' && !expression) {
|
||||
|
||||
@@ -185,15 +185,15 @@ describe('VariableManager', () => {
|
||||
|
||||
describe('formatForCodeContext', () => {
|
||||
it('should format plain type variables for code context', () => {
|
||||
expect(VariableManager.formatForCodeContext('hello world', 'plain')).toBe('"hello world"')
|
||||
expect(VariableManager.formatForCodeContext(42, 'plain')).toBe('"42"')
|
||||
expect(VariableManager.formatForCodeContext(true, 'plain')).toBe('"true"')
|
||||
expect(VariableManager.formatForCodeContext('hello world', 'plain')).toBe('hello world')
|
||||
expect(VariableManager.formatForCodeContext(42, 'plain')).toBe('42')
|
||||
expect(VariableManager.formatForCodeContext(true, 'plain')).toBe('true')
|
||||
})
|
||||
|
||||
it('should format string type variables for code context', () => {
|
||||
expect(VariableManager.formatForCodeContext('hello world', 'string')).toBe('"hello world"')
|
||||
expect(VariableManager.formatForCodeContext(42, 'string')).toBe('"42"')
|
||||
expect(VariableManager.formatForCodeContext(true, 'string')).toBe('"true"')
|
||||
expect(VariableManager.formatForCodeContext(42, 'string')).toBe('42')
|
||||
expect(VariableManager.formatForCodeContext(true, 'string')).toBe('true')
|
||||
})
|
||||
|
||||
it('should format number type variables for code context', () => {
|
||||
|
||||
@@ -31,16 +31,20 @@ export class VariableManager {
|
||||
if (forExecution) {
|
||||
return value
|
||||
}
|
||||
// For storage/display, convert to empty string for string types
|
||||
return type === 'string' || type === 'plain' ? '' : value
|
||||
// For storage/display, convert to empty string for text types
|
||||
return type === 'plain' || type === 'string' ? '' : value
|
||||
}
|
||||
|
||||
// For 'plain' type, we want to preserve quotes exactly as entered
|
||||
if (type === 'plain') {
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
}
|
||||
|
||||
// Remove quotes from string values if present (used by multiple types)
|
||||
const unquoted = typeof value === 'string' ? value.replace(/^["'](.*)["']$/s, '$1') : value
|
||||
|
||||
switch (type) {
|
||||
case 'plain':
|
||||
case 'string':
|
||||
case 'string': // Handle string type the same as plain for compatibility
|
||||
return String(unquoted)
|
||||
|
||||
case 'number':
|
||||
@@ -122,17 +126,18 @@ export class VariableManager {
|
||||
if (value === undefined) return context === 'code' ? 'undefined' : ''
|
||||
if (value === null) return context === 'code' ? 'null' : ''
|
||||
|
||||
// For plain type, preserve exactly as is without conversion
|
||||
if (type === 'plain') {
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
}
|
||||
|
||||
// Convert to native type first to ensure consistent handling
|
||||
// We don't use forExecution=true for formatting since we don't want to preserve null/undefined
|
||||
const typedValue = this.convertToNativeType(value, type, false)
|
||||
|
||||
switch (type) {
|
||||
case 'plain':
|
||||
case 'string':
|
||||
if (context === 'code') {
|
||||
// In code contexts, strings must be quoted
|
||||
return JSON.stringify(String(typedValue))
|
||||
}
|
||||
case 'string': // Handle string type the same as plain for compatibility
|
||||
// For plain text and strings, we don't add quotes in any context
|
||||
return String(typedValue)
|
||||
|
||||
case 'number':
|
||||
@@ -211,6 +216,19 @@ export class VariableManager {
|
||||
* Formats a value for use in code contexts with proper JavaScript syntax.
|
||||
*/
|
||||
static formatForCodeContext(value: any, type: VariableType): string {
|
||||
// Special handling for null/undefined in code context
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
|
||||
// For plain text, use exactly what the user typed, without any conversion
|
||||
// This may cause JavaScript errors if they don't enter valid JS code
|
||||
if (type === 'plain') {
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
} else if (type === 'string') {
|
||||
// For backwards compatibility, add quotes only for string type in code context
|
||||
return typeof value === 'string' ? JSON.stringify(value) : this.formatValue(value, type, 'code')
|
||||
}
|
||||
|
||||
return this.formatValue(value, type, 'code')
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,90 @@ const SAVE_DEBOUNCE_DELAY = 500 // 500ms debounce delay
|
||||
const saveTimers = new Map<string, NodeJS.Timeout>()
|
||||
// Track which workflows have already been loaded
|
||||
const loadedWorkflows = new Set<string>()
|
||||
// Track recently added variable IDs with timestamps
|
||||
const recentlyAddedVariables = new Map<string, number>()
|
||||
// Time window in ms to consider a variable as "recently added" (3 seconds)
|
||||
const RECENT_VARIABLE_WINDOW = 3000
|
||||
|
||||
// Clear a workspace from the loaded tracking when switching workspaces
|
||||
export function clearWorkflowVariablesTracking() {
|
||||
loadedWorkflows.clear()
|
||||
// Also clear any old entries from recentlyAddedVariables
|
||||
const now = Date.now()
|
||||
recentlyAddedVariables.forEach((timestamp, id) => {
|
||||
if (now - timestamp > RECENT_VARIABLE_WINDOW * 2) {
|
||||
recentlyAddedVariables.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variable format is valid according to type without modifying it
|
||||
* Only provides validation feedback - does not change the value
|
||||
*/
|
||||
function validateVariable(variable: Variable): string | undefined {
|
||||
try {
|
||||
// We only care about the validation result, not the parsed value
|
||||
switch (variable.type) {
|
||||
case 'number':
|
||||
// Check if it's a valid number
|
||||
if (isNaN(Number(variable.value))) {
|
||||
return 'Not a valid number'
|
||||
}
|
||||
break
|
||||
case 'boolean':
|
||||
// Check if it's a valid boolean
|
||||
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
|
||||
return 'Expected "true" or "false"'
|
||||
}
|
||||
break
|
||||
case 'object':
|
||||
// Check if it's a valid JSON object
|
||||
try {
|
||||
const parsed = JSON.parse(String(variable.value))
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return 'Not a valid JSON object'
|
||||
}
|
||||
} catch {
|
||||
return 'Invalid JSON object syntax'
|
||||
}
|
||||
break
|
||||
case 'array':
|
||||
// Check if it's a valid JSON array
|
||||
try {
|
||||
const parsed = JSON.parse(String(variable.value))
|
||||
if (!Array.isArray(parsed)) {
|
||||
return 'Not a valid JSON array'
|
||||
}
|
||||
} catch {
|
||||
return 'Invalid JSON array syntax'
|
||||
}
|
||||
break
|
||||
}
|
||||
return undefined
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : 'Invalid format'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a variable from 'string' type to 'plain' type
|
||||
* Handles the value conversion appropriately
|
||||
*/
|
||||
function migrateStringToPlain(variable: Variable): Variable {
|
||||
if (variable.type !== 'string') {
|
||||
return variable
|
||||
}
|
||||
|
||||
// Convert string type to plain
|
||||
const updated = {
|
||||
...variable,
|
||||
type: 'plain' as const,
|
||||
}
|
||||
|
||||
// For plain text, we want to preserve values exactly as they are,
|
||||
// including any quote characters that may be part of the text
|
||||
return updated
|
||||
}
|
||||
|
||||
export const useVariablesStore = create<VariablesStore>()(
|
||||
@@ -60,49 +140,52 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
// Handle initial value
|
||||
let variableValue = variable.value
|
||||
|
||||
// Auto-add quotes for string values if they aren't already quoted
|
||||
if (
|
||||
variable.type === 'string' &&
|
||||
typeof variableValue === 'string' &&
|
||||
variableValue.trim() !== ''
|
||||
) {
|
||||
// Only add quotes if not already properly quoted
|
||||
const trimmedValue = variableValue.trim()
|
||||
|
||||
// Check if entire string is already properly quoted
|
||||
const isAlreadyQuoted =
|
||||
(trimmedValue.startsWith('"') &&
|
||||
trimmedValue.endsWith('"') &&
|
||||
trimmedValue.length >= 2) ||
|
||||
(trimmedValue.startsWith("'") &&
|
||||
trimmedValue.endsWith("'") &&
|
||||
trimmedValue.length >= 2)
|
||||
|
||||
if (!isAlreadyQuoted) {
|
||||
// Escape any existing quotes in the content
|
||||
const escapedValue = variableValue.replace(/"/g, '\\"')
|
||||
variableValue = `"${escapedValue}"`
|
||||
}
|
||||
// Check for type conversion - only for backward compatibility
|
||||
if (variable.type === 'string') {
|
||||
variable.type = 'plain'
|
||||
}
|
||||
|
||||
// Create the new variable with empty value
|
||||
const newVariable: Variable = {
|
||||
id,
|
||||
workflowId: variable.workflowId,
|
||||
name: uniqueName,
|
||||
type: variable.type,
|
||||
value: variable.value || '',
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
// Check for validation errors without modifying the value
|
||||
const validationError = validateVariable(newVariable)
|
||||
if (validationError) {
|
||||
newVariable.validationError = validationError
|
||||
}
|
||||
|
||||
// Mark this variable as recently added with current timestamp
|
||||
recentlyAddedVariables.set(id, Date.now())
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
...state.variables,
|
||||
[id]: {
|
||||
id,
|
||||
workflowId: variable.workflowId,
|
||||
name: uniqueName,
|
||||
type: variable.type,
|
||||
value: variableValue,
|
||||
},
|
||||
[id]: newVariable,
|
||||
},
|
||||
}))
|
||||
|
||||
// Auto-save to DB
|
||||
get().saveVariables(variable.workflowId)
|
||||
// Use the same debounced save mechanism as updateVariable
|
||||
const workflowId = variable.workflowId
|
||||
|
||||
// Clear existing timer for this workflow if it exists
|
||||
if (saveTimers.has(workflowId)) {
|
||||
clearTimeout(saveTimers.get(workflowId))
|
||||
}
|
||||
|
||||
// Set new debounced save timer
|
||||
const timer = setTimeout(() => {
|
||||
get().saveVariables(workflowId)
|
||||
saveTimers.delete(workflowId)
|
||||
}, SAVE_DEBOUNCE_DELAY)
|
||||
|
||||
saveTimers.set(workflowId, timer)
|
||||
|
||||
return id
|
||||
},
|
||||
@@ -203,38 +286,27 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
update = { ...update, name: uniqueName }
|
||||
}
|
||||
|
||||
// Auto-add quotes for string values if they aren't already quoted
|
||||
if (
|
||||
update.value !== undefined &&
|
||||
state.variables[id].type === 'string' &&
|
||||
typeof update.value === 'string' &&
|
||||
update.value.trim() !== ''
|
||||
) {
|
||||
// Only add quotes if not already properly quoted
|
||||
const trimmedValue = update.value.trim()
|
||||
|
||||
// Check if entire string is already properly quoted
|
||||
const isAlreadyQuoted =
|
||||
(trimmedValue.startsWith('"') &&
|
||||
trimmedValue.endsWith('"') &&
|
||||
trimmedValue.length >= 2) ||
|
||||
(trimmedValue.startsWith("'") &&
|
||||
trimmedValue.endsWith("'") &&
|
||||
trimmedValue.length >= 2)
|
||||
|
||||
if (!isAlreadyQuoted) {
|
||||
// Escape any existing quotes in the content
|
||||
const escapedValue = update.value.replace(/"/g, '\\"')
|
||||
update = { ...update, value: `"${escapedValue}"` }
|
||||
}
|
||||
// If type is being updated to 'string', convert it to 'plain' instead
|
||||
if (update.type === 'string') {
|
||||
update = { ...update, type: 'plain' }
|
||||
}
|
||||
|
||||
// Create updated variable to check for validation
|
||||
const updatedVariable: Variable = {
|
||||
...state.variables[id],
|
||||
...update,
|
||||
validationError: undefined, // Initialize property to be updated later
|
||||
}
|
||||
|
||||
// If the type or value changed, check for validation errors
|
||||
if (update.type || update.value !== undefined) {
|
||||
// Only add validation feedback - never modify the value
|
||||
updatedVariable.validationError = validateVariable(updatedVariable)
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...state.variables,
|
||||
[id]: {
|
||||
...state.variables[id],
|
||||
...update,
|
||||
},
|
||||
[id]: updatedVariable,
|
||||
}
|
||||
|
||||
// Debounced auto-save to DB
|
||||
@@ -264,8 +336,19 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
const workflowId = state.variables[id].workflowId
|
||||
const { [id]: _, ...rest } = state.variables
|
||||
|
||||
// Auto-save to DB - no debounce for deletion
|
||||
setTimeout(() => get().saveVariables(workflowId), 0)
|
||||
// Use the same debounced save mechanism for consistency
|
||||
// Clear existing timer for this workflow if it exists
|
||||
if (saveTimers.has(workflowId)) {
|
||||
clearTimeout(saveTimers.get(workflowId))
|
||||
}
|
||||
|
||||
// Set new debounced save timer
|
||||
const timer = setTimeout(() => {
|
||||
get().saveVariables(workflowId)
|
||||
saveTimers.delete(workflowId)
|
||||
}, SAVE_DEBOUNCE_DELAY)
|
||||
|
||||
saveTimers.set(workflowId, timer)
|
||||
|
||||
return { variables: rest }
|
||||
})
|
||||
@@ -289,6 +372,9 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
uniqueName = `${baseName} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
// Mark this duplicated variable as recently added
|
||||
recentlyAddedVariables.set(newId, Date.now())
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
@@ -303,21 +389,78 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
},
|
||||
}))
|
||||
|
||||
// Auto-save to DB
|
||||
get().saveVariables(variable.workflowId)
|
||||
// Use the same debounced save mechanism
|
||||
const workflowId = variable.workflowId
|
||||
|
||||
// Clear existing timer for this workflow if it exists
|
||||
if (saveTimers.has(workflowId)) {
|
||||
clearTimeout(saveTimers.get(workflowId))
|
||||
}
|
||||
|
||||
// Set new debounced save timer
|
||||
const timer = setTimeout(() => {
|
||||
get().saveVariables(workflowId)
|
||||
saveTimers.delete(workflowId)
|
||||
}, SAVE_DEBOUNCE_DELAY)
|
||||
|
||||
saveTimers.set(workflowId, timer)
|
||||
|
||||
return newId
|
||||
},
|
||||
|
||||
loadVariables: async (workflowId) => {
|
||||
// Skip if already loaded to prevent redundant API calls
|
||||
if (loadedWorkflows.has(workflowId)) return
|
||||
// Skip if already loaded to prevent redundant API calls, but ensure
|
||||
// we check for the special case of recently added variables first
|
||||
if (loadedWorkflows.has(workflowId)) {
|
||||
// Even if workflow is loaded, check if we have recent variables to protect
|
||||
const workflowVariables = Object.values(get().variables)
|
||||
.filter((v) => v.workflowId === workflowId)
|
||||
|
||||
const now = Date.now()
|
||||
const hasRecentVariables = workflowVariables.some(v =>
|
||||
recentlyAddedVariables.has(v.id) &&
|
||||
(now - (recentlyAddedVariables.get(v.id) || 0) < RECENT_VARIABLE_WINDOW)
|
||||
)
|
||||
|
||||
// No force reload needed if no recent variables and we've already loaded
|
||||
if (!hasRecentVariables) {
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise continue and do a full load+merge to protect recent variables
|
||||
}
|
||||
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.WORKFLOWS}/${workflowId}/variables`)
|
||||
|
||||
// Capture current variables for this workflow before we modify anything
|
||||
const currentWorkflowVariables = Object.values(get().variables)
|
||||
.filter((v) => v.workflowId === workflowId)
|
||||
.reduce((acc, v) => {
|
||||
acc[v.id] = v
|
||||
return acc
|
||||
}, {} as Record<string, Variable>)
|
||||
|
||||
// Check which variables were recently added (within the last few seconds)
|
||||
const now = Date.now()
|
||||
const protectedVariableIds = new Set<string>()
|
||||
|
||||
// Identify variables that should be protected from being overwritten
|
||||
Object.keys(currentWorkflowVariables).forEach(id => {
|
||||
// Protect recently added variables
|
||||
if (recentlyAddedVariables.has(id) &&
|
||||
(now - (recentlyAddedVariables.get(id) || 0) < RECENT_VARIABLE_WINDOW)) {
|
||||
protectedVariableIds.add(id)
|
||||
}
|
||||
|
||||
// Also protect variables that are currently being edited (have pending changes)
|
||||
if (saveTimers.has(workflowId)) {
|
||||
protectedVariableIds.add(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle 404 workflow not found gracefully
|
||||
if (response.status === 404) {
|
||||
logger.info(`No variables found for workflow ${workflowId}, initializing empty set`)
|
||||
@@ -332,6 +475,13 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
},
|
||||
{} as Record<string, Variable>
|
||||
)
|
||||
|
||||
// Add back protected variables that should not be removed
|
||||
Object.keys(currentWorkflowVariables).forEach(id => {
|
||||
if (protectedVariableIds.has(id)) {
|
||||
otherVariables[id] = currentWorkflowVariables[id]
|
||||
}
|
||||
})
|
||||
|
||||
// Mark this workflow as loaded to prevent further attempts
|
||||
loadedWorkflows.add(workflowId)
|
||||
@@ -352,6 +502,12 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
set((state) => {
|
||||
// Migrate any 'string' type variables to 'plain'
|
||||
const migratedData: Record<string, Variable> = {}
|
||||
for (const [id, variable] of Object.entries(data)) {
|
||||
migratedData[id] = migrateStringToPlain(variable as Variable)
|
||||
}
|
||||
|
||||
// Merge with existing variables from other workflows
|
||||
const otherVariables = Object.values(state.variables).reduce(
|
||||
(acc, variable) => {
|
||||
@@ -362,12 +518,22 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
},
|
||||
{} as Record<string, Variable>
|
||||
)
|
||||
|
||||
// Create the final variables object, prioritizing protected variables
|
||||
const finalVariables = { ...otherVariables, ...migratedData }
|
||||
|
||||
// Restore any protected variables that shouldn't be overwritten
|
||||
Object.keys(currentWorkflowVariables).forEach(id => {
|
||||
if (protectedVariableIds.has(id)) {
|
||||
finalVariables[id] = currentWorkflowVariables[id]
|
||||
}
|
||||
})
|
||||
|
||||
// Mark this workflow as loaded
|
||||
loadedWorkflows.add(workflowId)
|
||||
|
||||
return {
|
||||
variables: { ...otherVariables, ...data },
|
||||
variables: finalVariables,
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
@@ -383,6 +549,13 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
},
|
||||
{} as Record<string, Variable>
|
||||
)
|
||||
|
||||
// Add back protected variables that should not be removed
|
||||
Object.keys(currentWorkflowVariables).forEach(id => {
|
||||
if (protectedVariableIds.has(id)) {
|
||||
otherVariables[id] = currentWorkflowVariables[id]
|
||||
}
|
||||
})
|
||||
|
||||
// Mark this workflow as loaded
|
||||
loadedWorkflows.add(workflowId)
|
||||
@@ -417,6 +590,12 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
const workflowVariables = Object.values(get().variables).filter(
|
||||
(variable) => variable.workflowId === workflowId
|
||||
)
|
||||
|
||||
// Record the last save attempt timestamp for each variable to track sync state
|
||||
workflowVariables.forEach(variable => {
|
||||
// Mark save attempt time for all variables being saved
|
||||
recentlyAddedVariables.set(variable.id, Date.now())
|
||||
})
|
||||
|
||||
// Send to DB
|
||||
const response = await fetch(`${API_ENDPOINTS.WORKFLOWS}/${workflowId}/variables`, {
|
||||
@@ -451,10 +630,8 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
// Reload from DB to ensure consistency
|
||||
// Reset tracking to force a reload
|
||||
loadedWorkflows.delete(workflowId)
|
||||
get().loadVariables(workflowId)
|
||||
// Don't reload variables after save error - this could cause data loss
|
||||
// Just clear the loading state
|
||||
}
|
||||
},
|
||||
|
||||
@@ -467,6 +644,14 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
// Reset the loaded workflow tracking
|
||||
resetLoaded: () => {
|
||||
loadedWorkflows.clear()
|
||||
|
||||
// Clean up stale entries from recentlyAddedVariables
|
||||
const now = Date.now()
|
||||
recentlyAddedVariables.forEach((timestamp, id) => {
|
||||
if (now - timestamp > RECENT_VARIABLE_WINDOW * 2) {
|
||||
recentlyAddedVariables.delete(id)
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
|
||||
/**
|
||||
* Variable types supported in the application
|
||||
* Note: 'string' is deprecated - use 'plain' for text values instead
|
||||
*/
|
||||
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
|
||||
|
||||
/**
|
||||
* Represents a workflow variable with workflow-specific naming
|
||||
@@ -10,6 +14,7 @@ export interface Variable {
|
||||
name: string // Must be unique per workflow
|
||||
type: VariableType
|
||||
value: any
|
||||
validationError?: string // Tracks format validation errors
|
||||
}
|
||||
|
||||
export interface VariablesStore {
|
||||
|
||||
@@ -85,7 +85,6 @@ describe('Function Execute Tool', () => {
|
||||
output: {
|
||||
result: 42,
|
||||
stdout: 'console.log output',
|
||||
executionTime: 15,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -98,7 +97,6 @@ describe('Function Execute Tool', () => {
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.result).toBe(42)
|
||||
expect(result.output.stdout).toBe('console.log output')
|
||||
expect(result.output.executionTime).toBe(15)
|
||||
})
|
||||
|
||||
test('should handle execution errors', async () => {
|
||||
|
||||
@@ -55,7 +55,6 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
|
||||
output: {
|
||||
result: result.output.result,
|
||||
stdout: result.output.stdout,
|
||||
executionTime: result.output.executionTime,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,6 +10,5 @@ export interface CodeExecutionOutput extends ToolResponse {
|
||||
output: {
|
||||
result: any
|
||||
stdout: string
|
||||
executionTime: number
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user