feat(vars) (#182)

* feat(vars) (#181)

* feat(vars): started panel

* improvement(variables): styling and connection to DB

* fix(variables): GET fix

* improvement(vars): sync, styling

* feat(vars): execution; styling
This commit is contained in:
Emir Karabeg
2025-03-25 02:48:42 -07:00
committed by GitHub
parent c2499f6623
commit 9da64439a2
25 changed files with 2652 additions and 92 deletions
@@ -0,0 +1,151 @@
import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { workflow } from '@/db/schema'
import { Variable } from '@/stores/panel/variables/types'
const logger = createLogger('WorkflowVariablesAPI')
// Schema for workflow variables updates
const VariablesSchema = z.object({
variables: z.array(
z.object({
id: z.string(),
workflowId: z.string(),
name: z.string(),
type: z.enum(['string', 'number', 'boolean', 'object', 'array']),
value: z.union([z.string(), z.number(), z.boolean(), z.record(z.any()), z.array(z.any())]),
})
),
})
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = crypto.randomUUID().slice(0, 8)
const workflowId = (await params).id
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if the workflow belongs to the user
const workflowRecord = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord.length) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
if (workflowRecord[0].userId !== session.user.id) {
logger.warn(
`[${requestId}] User ${session.user.id} attempted to update variables for workflow ${workflowId} owned by ${workflowRecord[0].userId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
try {
const { variables } = VariablesSchema.parse(body)
// Format variables for storage
const variablesRecord: Record<string, Variable> = {}
variables.forEach((variable) => {
variablesRecord[variable.id] = variable
})
// Update workflow with variables
await db
.update(workflow)
.set({
variables: variablesRecord,
updatedAt: new Date(),
})
.where(eq(workflow.id, workflowId))
return NextResponse.json({ success: true })
} catch (validationError) {
if (validationError instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid workflow variables data`, {
errors: validationError.errors,
})
return NextResponse.json(
{ error: 'Invalid request data', details: validationError.errors },
{ status: 400 }
)
}
throw validationError
}
} catch (error) {
logger.error(`[${requestId}] Error updating workflow variables`, error)
return NextResponse.json(
{ error: 'Failed to update workflow variables' },
{ status: 500 }
)
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = crypto.randomUUID().slice(0, 8)
const workflowId = (await params).id
try {
// Get the session directly in the API route
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if the workflow belongs to the user
const workflowRecord = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord.length) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
if (workflowRecord[0].userId !== session.user.id) {
logger.warn(
`[${requestId}] User ${session.user.id} attempted to access variables for workflow ${workflowId} owned by ${workflowRecord[0].userId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Return variables if they exist
const variables = workflowRecord[0].variables as Record<string, Variable> || {}
// Add cache headers to prevent frequent reloading
const headers = new Headers({
'Cache-Control': 'max-age=60, stale-while-revalidate=300', // Cache for 1 minute, stale for 5
'ETag': `"${requestId}-${Object.keys(variables).length}"`,
})
return NextResponse.json({ data: variables }, {
status: 200,
headers,
})
} catch (error: any) {
logger.error(`[${requestId}] Workflow variables fetch error`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'
import { format } from 'date-fns'
import { AlertCircle, AlertTriangle, Calendar, CheckCircle2, Clock, Terminal } from 'lucide-react'
import { ConsoleEntry as ConsoleEntryType } from '@/stores/console/types'
import { ConsoleEntry as ConsoleEntryType } from '@/stores/panel/console/types'
import { getBlock } from '@/blocks'
import { JSONView } from '../json-view/json-view'
@@ -0,0 +1,36 @@
'use client'
import { useMemo } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useConsoleStore } from '@/stores/panel/console/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { ConsoleEntry } from './components/console-entry/console-entry'
interface ConsoleProps {
panelWidth: number
}
export function Console({ panelWidth }: ConsoleProps) {
const entries = useConsoleStore((state) => state.entries)
const { activeWorkflowId } = useWorkflowRegistry()
const filteredEntries = useMemo(() => {
return entries.filter((entry) => entry.workflowId === activeWorkflowId)
}, [entries, activeWorkflowId])
return (
<ScrollArea className="h-full">
<div className="pb-16">
{filteredEntries.length === 0 ? (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground pt-4">
No console entries
</div>
) : (
filteredEntries.map((entry) => (
<ConsoleEntry key={entry.id} entry={entry} consoleWidth={panelWidth} />
))
)}
</div>
</ScrollArea>
)
}
@@ -0,0 +1,327 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Copy, MoreVertical, Plus, Trash } from 'lucide-react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
import Editor from 'react-simple-code-editor'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useVariablesStore } from '../../../../../../../stores/panel/variables/store'
import { Variable, VariableType } from '../../../../../../../stores/panel/variables/types'
interface VariablesProps {
panelWidth: number
}
export function Variables({ panelWidth }: VariablesProps) {
const { activeWorkflowId, workflows } = useWorkflowRegistry()
const {
variables: storeVariables,
addVariable,
updateVariable,
deleteVariable,
duplicateVariable,
getVariablesByWorkflowId,
loadVariables,
} = useVariablesStore()
// Get variables for the current workflow
const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
// Load variables when workflow changes
useEffect(() => {
if (activeWorkflowId && workflows[activeWorkflowId]) {
loadVariables(activeWorkflowId)
}
}, [activeWorkflowId, workflows, loadVariables])
// Track editor references
const editorRefs = useRef<Record<string, HTMLDivElement | null>>({})
// Auto-save when variables are added/edited
const handleAddVariable = () => {
if (!activeWorkflowId) return
// Create a default variable - naming is handled in the store
const id = addVariable({
name: '', // Store will generate an appropriate name
type: 'string',
value: '',
workflowId: activeWorkflowId,
})
return id
}
const getTypeIcon = (type: VariableType) => {
switch (type) {
case 'string':
return 'Aa'
case 'number':
return '123'
case 'boolean':
return '0/1'
case 'object':
return '{}'
case 'array':
return '[]'
default:
return '?'
}
}
const getPlaceholder = (type: VariableType) => {
switch (type) {
case 'string':
return '"Hello world"'
case 'number':
return '42'
case 'boolean':
return 'true'
case 'object':
return '{\n "key": "value"\n}'
case 'array':
return '[\n 1,\n 2,\n 3\n]'
default:
return ''
}
}
const getEditorLanguage = (type: VariableType) => {
switch (type) {
case 'object':
case 'array':
case 'boolean':
case 'number':
return 'javascript'
default:
return 'javascript'
}
}
const formatValue = (variable: Variable) => {
if (variable.value === '') return ''
try {
if (variable.type === 'object' || variable.type === 'array') {
// Try to prettify if it's JSON
const parsed = JSON.parse(variable.value as string)
return JSON.stringify(parsed, null, 2)
}
// For string type, remove surrounding quotes for display
if (variable.type === 'string') {
const value = variable.value as string
const trimmed = value.trim()
// Remove surrounding quotes if they exist
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
// Get the content between quotes and unescape any internal quotes
return trimmed.slice(1, -1).replace(/\\"/g, '"')
}
return value
}
} catch (e) {
// If not valid JSON, return as is
}
return variable.value as string
}
// Clear editor refs when variables change
useEffect(() => {
// Clean up any references to deleted variables
Object.keys(editorRefs.current).forEach((id) => {
if (!workflowVariables.some((v) => v.id === id)) {
delete editorRefs.current[id]
}
})
}, [workflowVariables])
// Handle editor value changes
const handleEditorChange = (variable: Variable, newValue: string) => {
// For string type, we send the raw input value so the store can handle quoting
updateVariable(variable.id, { value: newValue })
}
return (
<ScrollArea className="h-full">
<div className="p-4 pb-16 space-y-3">
{/* Variables List */}
{workflowVariables.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-sm text-muted-foreground pt-4">
<div className="mb-2">No variables yet</div>
<Button variant="outline" size="sm" className="text-xs" onClick={handleAddVariable}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add your first variable
</Button>
</div>
) : (
<>
<div className="space-y-3">
{workflowVariables.map((variable) => (
<div
key={variable.id}
className="group flex flex-col space-y-2 rounded-lg border bg-background shadow-sm"
>
<div className="flex items-center justify-between p-3 border-b bg-muted/30">
<div className="flex-1 flex items-center gap-2">
<Input
className="h-9 bg-background border-input focus-visible:ring-1 focus-visible:ring-ring max-w-40 !text-md"
placeholder="Variable name"
value={variable.name}
onChange={(e) => updateVariable(variable.id, { name: e.target.value })}
/>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-1">
<span className="text-sm !font-mono pt-[0.3px]">
{getTypeIcon(variable.type)}
</span>
<ChevronDown className="!h-3.5 !w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Set variable type</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-32">
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'string' })}
className="cursor-pointer flex items-center"
>
<div className="w-5 text-center mr-2 font-mono text-sm">Aa</div>
<span>String</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'number' })}
className="cursor-pointer flex items-center"
>
<div className="w-5 text-center mr-2 font-mono text-sm">123</div>
<span>Number</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'boolean' })}
className="cursor-pointer flex items-center"
>
<div className="w-5 text-center mr-2 font-mono text-sm">0/1</div>
<span>Boolean</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'object' })}
className="cursor-pointer flex items-center"
>
<div className="w-5 text-center mr-2 font-mono text-sm">{'{}'}</div>
<span>Object</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'array' })}
className="cursor-pointer flex items-center"
>
<div className="w-5 text-center mr-2 font-mono text-sm">[]</div>
<span>Array</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => duplicateVariable(variable.id)}
className="cursor-pointer text-muted-foreground"
>
<Copy className="h-4 w-4 mr-2 text-muted-foreground" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => deleteVariable(variable.id)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div
className="relative min-h-[36px] rounded-md bg-background font-mono text-sm px-4 pt-2 pb-3"
ref={(el) => {
editorRefs.current[variable.id] = el
}}
>
{variable.value === '' && (
<div className="absolute top-[8.5px] left-4 text-muted-foreground/50 pointer-events-none select-none">
{getPlaceholder(variable.type)}
</div>
)}
<Editor
key={`editor-${variable.id}-${variable.type}`}
value={formatValue(variable)}
onValueChange={handleEditorChange.bind(null, variable)}
highlight={(code) =>
highlight(
code,
languages[getEditorLanguage(variable.type)],
getEditorLanguage(variable.type)
)
}
padding={0}
style={{
fontFamily: 'inherit',
lineHeight: '21px',
}}
className="focus:outline-none w-full"
textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap"
/>
</div>
</div>
))}
</div>
{/* Add Variable Button */}
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs w-full justify-start text-muted-foreground hover:text-foreground"
onClick={handleAddVariable}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add variable
</Button>
</>
)}
</div>
</ScrollArea>
)
}
@@ -1,28 +1,27 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { PanelLeftClose, PanelRightClose, Terminal } from 'lucide-react'
import { useEffect, useState } from 'react'
import { PanelRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useConsoleStore } from '@/stores/console/store'
import { useConsoleStore } from '@/stores/panel/console/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { ConsoleEntry } from './components/console-entry/console-entry'
import { usePanelStore } from '../../../../../stores/panel/store'
import { Console } from './components/console/console'
import { Variables } from './components/variables/variables'
export function Console() {
export function Panel() {
const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width)
const [isDragging, setIsDragging] = useState(false)
const isOpen = useConsoleStore((state) => state.isOpen)
const toggleConsole = useConsoleStore((state) => state.toggleConsole)
const entries = useConsoleStore((state) => state.entries)
const isOpen = usePanelStore((state) => state.isOpen)
const togglePanel = usePanelStore((state) => state.togglePanel)
const activeTab = usePanelStore((state) => state.activeTab)
const setActiveTab = usePanelStore((state) => state.setActiveTab)
const clearConsole = useConsoleStore((state) => state.clearConsole)
const { activeWorkflowId } = useWorkflowRegistry()
const filteredEntries = useMemo(() => {
return entries.filter((entry) => entry.workflowId === activeWorkflowId)
}, [entries, activeWorkflowId])
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true)
e.preventDefault()
@@ -56,14 +55,14 @@ export function Console() {
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleConsole}
onClick={togglePanel}
className="fixed right-4 bottom-[18px] z-10 flex h-9 w-9 items-center justify-center rounded-lg bg-background text-muted-foreground transition-colors hover:text-foreground hover:bg-accent border"
>
<Terminal className="h-5 w-5" />
<span className="sr-only">Open Console</span>
<PanelRight className="h-5 w-5" />
<span className="sr-only">Open Panel</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">Open Console</TooltipContent>
<TooltipContent side="top">Open Panel</TooltipContent>
</Tooltip>
)
}
@@ -79,43 +78,61 @@ export function Console() {
/>
<div className="flex items-center justify-between h-14 px-4 border-b">
<h2 className="text-sm font-medium">Console</h2>
<Button
variant="ghost"
size="sm"
onClick={() => clearConsole(activeWorkflowId)}
className="text-muted-foreground hover:text-foreground"
>
Clear
</Button>
<div className="flex gap-2">
<button
onClick={() => setActiveTab('console')}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'console'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
Console
</button>
<button
onClick={() => setActiveTab('variables')}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'variables'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
Variables
</button>
</div>
{activeTab === 'console' && (
<button
onClick={() => clearConsole(activeWorkflowId)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
true ? 'text-muted-foreground hover:text-foreground hover:bg-accent/50' : ''
}`}
>
Clear
</button>
)}
</div>
<ScrollArea className="h-[calc(100%-4rem)]">
<div className="pb-16">
{filteredEntries.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground pt-4">
No console entries
</div>
) : (
filteredEntries.map((entry) => (
<ConsoleEntry key={entry.id} entry={entry} consoleWidth={width} />
))
)}
</div>
</ScrollArea>
<div className="h-[calc(100%-4rem)]">
{activeTab === 'console' ? (
<Console panelWidth={width} />
) : (
<Variables panelWidth={width} />
)}
</div>
<div className="absolute left-0 right-0 bottom-0 h-16 bg-background border-t">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleConsole}
onClick={togglePanel}
className="absolute left-4 bottom-[18px] flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:bg-accent"
>
<PanelRightClose className="h-5 w-5" />
<span className="sr-only">Close Console</span>
<PanelRight className="h-5 w-5 transform rotate-180" />
<span className="sr-only">Close Panel</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">Close Console</TooltipContent>
<TooltipContent side="right">Close Panel</TooltipContent>
</Tooltip>
</div>
</div>
+15 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger'
import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { useConsoleStore } from '@/stores/console/store'
import { useConsoleStore } from '@/stores/panel/console/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useNotificationStore } from '@/stores/notifications/store'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
@@ -10,6 +10,7 @@ import { useGeneralStore } from '@/stores/settings/general/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { Executor } from '@/executor'
import { ExecutionResult } from '@/executor/types'
import { Serializer } from '@/serializer'
@@ -23,6 +24,7 @@ export function useWorkflowExecution() {
const { toggleConsole } = useConsoleStore()
const { getAllVariables } = useEnvironmentStore()
const { isDebugModeEnabled } = useGeneralStore()
const { getVariablesByWorkflowId, variables } = useVariablesStore()
const {
isExecuting,
isDebugging,
@@ -117,11 +119,21 @@ export function useWorkflowExecution() {
{} as Record<string, string>
)
// Get workflow variables
const workflowVars = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
const workflowVariables = workflowVars.reduce(
(acc, variable) => {
acc[variable.id] = variable
return acc
},
{} as Record<string, any>
)
// Create serialized workflow
const workflow = new Serializer().serializeWorkflow(mergedStates, edges, loops)
// Create executor and store in global state
const newExecutor = new Executor(workflow, currentBlockStates, envVarValues)
const newExecutor = new Executor(workflow, currentBlockStates, envVarValues, workflowVariables)
setExecutor(newExecutor)
// Execute workflow
@@ -246,6 +258,7 @@ export function useWorkflowExecution() {
addNotification,
toggleConsole,
getAllVariables,
getVariablesByWorkflowId,
setIsExecuting,
setIsDebugging,
isDebugModeEnabled,
+2 -2
View File
@@ -1,7 +1,7 @@
import { Chat } from './components/chat/chat'
import { Console } from './components/console/console'
import { ControlBar } from './components/control-bar/control-bar'
import { ErrorBoundary } from './components/error'
import { Panel } from './components/panel/panel'
import { Toolbar } from './components/toolbar/toolbar'
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
@@ -10,7 +10,7 @@ export default function WorkflowLayout({ children }: { children: React.ReactNode
<ControlBar />
<Toolbar />
{/* <Chat /> */}
<Console />
<Panel />
<main className="grid items-start gap-2 bg-muted/40 h-[calc(100vh-4rem)]">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
+7
View File
@@ -14,6 +14,7 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@/lib/logs/console-logger'
import { useExecutionStore } from '@/stores/execution/store'
import { useNotificationStore } from '@/stores/notifications/store'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { initializeSyncManagers, isSyncInitialized } from '@/stores/sync-registry'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -54,6 +55,7 @@ function WorkflowContent() {
useWorkflowStore()
const { setValue: setSubBlockValue } = useSubBlockStore()
const { markAllAsRead } = useNotificationStore()
const { resetLoaded: resetVariablesLoaded } = useVariablesStore()
// Execution and debug mode state
const { activeBlockIds, pendingBlocks } = useExecutionStore()
@@ -163,6 +165,8 @@ function WorkflowContent() {
const checkInterval = setInterval(() => {
if (!isActivelyLoadingFromDB()) {
clearInterval(checkInterval)
// Reset variables loaded state before setting active workflow
resetVariablesLoaded()
setActiveWorkflow(currentId)
markAllAsRead(currentId)
}
@@ -170,6 +174,8 @@ function WorkflowContent() {
return
}
// Reset variables loaded state before setting active workflow
resetVariablesLoaded()
setActiveWorkflow(currentId)
markAllAsRead(currentId)
}
@@ -183,6 +189,7 @@ function WorkflowContent() {
router,
isInitialized,
markAllAsRead,
resetVariablesLoaded,
])
// Transform blocks and loops into ReactFlow nodes
+187 -28
View File
@@ -1,8 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { Variable } from '@/stores/panel/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getBlock } from '@/blocks'
const logger = createLogger('TagDropdown')
@@ -76,6 +80,20 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
// Get available tags from workflow state
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Get variables from variables store
const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId)
const loadVariables = useVariablesStore((state) => state.loadVariables)
const variables = useVariablesStore((state) => state.variables)
const workflowVariables = workflowId ? getVariablesByWorkflowId(workflowId) : []
// Load variables when workflowId changes
useEffect(() => {
if (workflowId) {
loadVariables(workflowId)
}
}, [workflowId, loadVariables])
// Extract search term from input
const searchTerm = useMemo(() => {
@@ -85,7 +103,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}, [inputValue, cursorPosition])
// Get source block and compute tags
const { tags } = useMemo(() => {
const { tags, variableInfoMap = {} } = useMemo(() => {
// Helper function to get output paths
const getOutputPaths = (obj: any, prefix = '', isStarterBlock = false): string[] => {
if (typeof obj !== 'object' || obj === null) {
@@ -108,10 +126,28 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
})
}
// Variables as tags - format as variable.{variableName}
const variableTags = workflowVariables.map(
(variable: Variable) => `variable.${variable.name.replace(/\s+/g, '')}`
)
// Create a map of variable tags to their type information
const variableInfoMap = workflowVariables.reduce(
(acc, variable) => {
const tagName = `variable.${variable.name.replace(/\s+/g, '')}`
acc[tagName] = {
type: variable.type,
id: variable.id,
}
return acc
},
{} as Record<string, { type: string; id: string }>
)
// If we have an active source block ID from a drop, use that specific block only
if (activeSourceBlockId) {
const sourceBlock = blocks[activeSourceBlockId]
if (!sourceBlock) return { tags: [] }
if (!sourceBlock) return { tags: [...variableTags] }
const blockName = sourceBlock.name || sourceBlock.type
const normalizedBlockName = blockName.replace(/\s+/g, '').toLowerCase()
@@ -124,9 +160,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
.getValue(activeSourceBlockId, 'metrics') as unknown as Metric[]
if (Array.isArray(metricsValue)) {
return {
tags: metricsValue.map(
(metric) => `${normalizedBlockName}.response.${metric.name.toLowerCase()}`
),
tags: [
...variableTags,
...metricsValue.map(
(metric) => `${normalizedBlockName}.response.${metric.name.toLowerCase()}`
),
],
}
}
} catch (e) {
@@ -149,7 +188,10 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const fields = extractFieldsFromSchema(responseFormat)
if (fields.length > 0) {
return {
tags: fields.map((field: Field) => `${normalizedBlockName}.response.${field.name}`),
tags: [
...variableTags,
...fields.map((field: Field) => `${normalizedBlockName}.response.${field.name}`),
],
}
}
}
@@ -161,7 +203,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
// Fall back to default outputs if no response format
const outputPaths = getOutputPaths(sourceBlock.outputs, '', sourceBlock.type === 'starter')
return {
tags: outputPaths.map((path) => `${normalizedBlockName}.${path}`),
tags: [...variableTags, ...outputPaths.map((path) => `${normalizedBlockName}.${path}`)],
}
}
@@ -217,8 +259,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
return outputPaths.map((path) => `${normalizedBlockName}.${path}`)
})
return { tags: sourceTags }
}, [blocks, edges, blockId, activeSourceBlockId])
return { tags: [...variableTags, ...sourceTags], variableInfoMap }
}, [blocks, edges, blockId, activeSourceBlockId, workflowVariables])
// Filter tags based on search term
const filteredTags = useMemo(() => {
@@ -226,6 +268,22 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
return tags.filter((tag: string) => tag.toLowerCase().includes(searchTerm))
}, [tags, searchTerm])
// Group tags into variables and blocks
const { variableTags, blockTags } = useMemo(() => {
const varTags: string[] = []
const blkTags: string[] = []
filteredTags.forEach((tag) => {
if (tag.startsWith('variable.')) {
varTags.push(tag)
} else {
blkTags.push(tag)
}
})
return { variableTags: varTags, blockTags: blkTags }
}, [filteredTags])
// Reset selection when filtered results change
useEffect(() => {
setSelectedIndex(0)
@@ -240,7 +298,25 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
if (lastOpenBracket === -1) return
const newValue = textBeforeCursor.slice(0, lastOpenBracket) + '<' + tag + '>' + textAfterCursor
// Process the tag if it's a variable tag
let processedTag = tag
if (tag.startsWith('variable.')) {
// Get the variable name from the tag (after 'variable.')
const variableName = tag.substring('variable.'.length)
// Find the variable in the store by name
const variableObj = Object.values(variables).find(
(v) => v.name.replace(/\s+/g, '') === variableName
)
// We still use the full tag format internally to maintain compatibility
if (variableObj) {
processedTag = tag
}
}
const newValue =
textBeforeCursor.slice(0, lastOpenBracket) + '<' + processedTag + '>' + textAfterCursor
onSelect(newValue)
onClose?.()
@@ -296,24 +372,107 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
{filteredTags.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">No matching tags found</div>
) : (
filteredTags.map((tag: string, index: number) => (
<button
key={tag}
className={cn(
'w-full px-3 py-1.5 text-sm text-left',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
index === selectedIndex && 'bg-accent text-accent-foreground'
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
handleTagSelect(tag)
}}
>
{tag}
</button>
))
<>
{variableTags.length > 0 && (
<>
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Variables
</div>
<div className="-mx-1 -px-1">
{variableTags.map((tag: string, index: number) => {
const variableInfo = variableInfoMap?.[tag] || null
const tagIndex = filteredTags.indexOf(tag)
return (
<button
key={tag}
className={cn(
'w-full px-3 py-1.5 text-sm text-left flex items-center gap-2',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
tagIndex === selectedIndex && 'bg-accent text-accent-foreground'
)}
onMouseEnter={() => setSelectedIndex(tagIndex)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
handleTagSelect(tag)
}}
>
<div
className="flex items-center justify-center w-5 h-5 rounded"
style={{ backgroundColor: '#2F8BFF' }}
>
<span className="w-3 h-3 text-white font-bold text-xs">V</span>
</div>
<span className="flex-1 truncate">
{tag.startsWith('variable.') ? tag.substring('variable.'.length) : tag}
</span>
{variableInfo && (
<span className="ml-auto text-xs text-muted-foreground">
{variableInfo.type}
</span>
)}
</button>
)
})}
</div>
</>
)}
{blockTags.length > 0 && (
<>
{variableTags.length > 0 && <div className="my-0" />}
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Blocks
</div>
<div className="-mx-1 -px-1">
{blockTags.map((tag: string, index: number) => {
const tagIndex = filteredTags.indexOf(tag)
// Get block name from tag (first part before the dot)
const blockName = tag.split('.')[0]
// Get block type from blocks
const blockType = Object.values(blocks).find(
(block) =>
(block.name || block.type).replace(/\s+/g, '').toLowerCase() === blockName
)?.type
// Get block color from block config
const blockConfig = blockType ? getBlock(blockType) : null
const blockColor = blockConfig?.bgColor || '#2F55FF' // Default to blue if not found
return (
<button
key={tag}
className={cn(
'w-full px-3 py-1.5 text-sm text-left flex items-center gap-2',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
tagIndex === selectedIndex && 'bg-accent text-accent-foreground'
)}
onMouseEnter={() => setSelectedIndex(tagIndex)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
handleTagSelect(tag)
}}
>
<div
className="flex items-center justify-center w-5 h-5 rounded"
style={{ backgroundColor: blockColor }}
>
<span className="w-3 h-3 text-white font-bold text-xs">
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className="flex-1 truncate">{tag}</span>
</button>
)
})}
</div>
</>
)}
</>
)}
</div>
</div>
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "workflow" ADD COLUMN "variables" json DEFAULT '{}';
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -155,6 +155,13 @@
"when": 1742850849852,
"tag": "0021_shocking_korath",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1742889909342,
"tag": "0022_gray_galactus",
"breakpoints": true
}
]
}
}
+1
View File
@@ -78,6 +78,7 @@ export const workflow = pgTable('workflow', {
collaborators: json('collaborators').notNull().default('[]'),
runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'),
variables: json('variables').default('{}'),
})
export const waitlist = pgTable('waitlist', {
+3 -2
View File
@@ -1,5 +1,5 @@
import { createLogger } from '@/lib/logs/console-logger'
import { useConsoleStore } from '@/stores/console/store'
import { useConsoleStore } from '@/stores/panel/console/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { BlockOutput } from '@/blocks/types'
@@ -39,12 +39,13 @@ export class Executor {
private workflow: SerializedWorkflow,
private initialBlockStates: Record<string, BlockOutput> = {},
private environmentVariables: Record<string, string> = {},
private workflowVariables: Record<string, any> = {},
workflowInput?: any
) {
this.validateWorkflow()
this.workflowInput = workflowInput || {}
this.resolver = new InputResolver(workflow, environmentVariables)
this.resolver = new InputResolver(workflow, environmentVariables, workflowVariables)
this.loopManager = new LoopManager(workflow.loops || {})
this.pathTracker = new PathTracker(workflow)
+55 -5
View File
@@ -10,7 +10,8 @@ export class InputResolver {
constructor(
private workflow: SerializedWorkflow,
private environmentVariables: Record<string, string>
private environmentVariables: Record<string, string>,
private workflowVariables: Record<string, any> = {}
) {
// Create maps for efficient lookups
this.blockById = new Map(workflow.blocks.map((block) => [block.id, block]))
@@ -59,8 +60,11 @@ export class InputResolver {
// Handle string values that may contain references
if (typeof value === 'string') {
// Resolve block references
let resolvedValue = this.resolveBlockReferences(value, context, block)
// First check for variable references
let resolvedValue = this.resolveVariableReferences(value)
// Then resolve block references
resolvedValue = this.resolveBlockReferences(resolvedValue, context, block)
// Check if this is an API key field
const isApiKey =
@@ -96,6 +100,44 @@ export class InputResolver {
return result
}
/**
* Resolves workflow variable references in a string (<variable.name>).
*
* @param value - String containing variable references
* @returns String with resolved variable references
*/
resolveVariableReferences(value: string): string {
const variableMatches = value.match(/<variable\.([^>]+)>/g)
if (!variableMatches) return value
let resolvedValue = value
for (const match of variableMatches) {
const variableName = match.slice('<variable.'.length, -1)
// Find the variable by normalized name (without spaces)
const foundVariable = Object.entries(this.workflowVariables).find(
([_, variable]) => {
const normalizedName = (variable.name || '').replace(/\s+/g, '')
return normalizedName === variableName
}
)
if (foundVariable) {
const [_, variable] = foundVariable
// Format the value appropriately
const formattedValue =
typeof variable.value === 'object'
? JSON.stringify(variable.value)
: String(variable.value)
resolvedValue = resolvedValue.replace(match, formattedValue)
}
}
return resolvedValue
}
/**
* Resolves block references in a string (<blockId.property> or <blockName.property>).
* Handles inactive paths, missing blocks, and formats values appropriately.
@@ -117,6 +159,11 @@ export class InputResolver {
let resolvedValue = value
for (const match of blockMatches) {
// Skip variables - they've already been processed
if (match.startsWith('<variable.')) {
continue
}
const path = match.slice(1, -1)
const [blockRef, ...pathParts] = path.split('.')
@@ -359,8 +406,11 @@ export class InputResolver {
// Handle strings
if (typeof value === 'string') {
// First resolve block references
const resolvedReferences = this.resolveBlockReferences(value, context, currentBlock)
// First resolve variable references
const resolvedVars = this.resolveVariableReferences(value)
// Then resolve block references
const resolvedReferences = this.resolveBlockReferences(resolvedVars, context, currentBlock)
// Check if this is an API key field
const isApiKey = this.isApiKeyField(currentBlock, value)
+1
View File
@@ -8,6 +8,7 @@ export const API_ENDPOINTS = {
WORKFLOW: '/api/db/workflow',
ENVIRONMENT: '/api/db/environment',
SCHEDULE: '/api/schedules/schedule',
WORKFLOW_VARIABLES: '/api/db/workflow/variables',
}
export const SYNC_INTERVALS = {
+5 -1
View File
@@ -1,11 +1,12 @@
import { useEffect } from 'react'
import { createLogger } from '@/lib/logs/console-logger'
import { useChatStore } from './chat/store'
import { useConsoleStore } from './console/store'
import { useConsoleStore } from './panel/console/store'
import { useCustomToolsStore } from './custom-tools/store'
import { useExecutionStore } from './execution/store'
import { useNotificationStore } from './notifications/store'
import { useEnvironmentStore } from './settings/environment/store'
import { useVariablesStore } from './panel/variables/store'
import { getSyncManagers, initializeSyncManagers, resetSyncManagers } from './sync-registry'
import {
loadRegistry,
@@ -228,6 +229,7 @@ export {
useConsoleStore,
useChatStore,
useCustomToolsStore,
useVariablesStore,
}
// Helper function to reset all stores
@@ -251,6 +253,7 @@ export const resetAllStores = () => {
useConsoleStore.setState({ entries: [], isOpen: false })
useChatStore.setState({ messages: [], isProcessing: false, error: null })
useCustomToolsStore.setState({ tools: {} })
useVariablesStore.getState().resetLoaded() // Reset variables store tracking
}
// Helper function to log all store states
@@ -265,6 +268,7 @@ export const logAllStores = () => {
chat: useChatStore.getState(),
customTools: useCustomToolsStore.getState(),
subBlock: useSubBlockStore.getState(),
variables: useVariablesStore.getState(),
}
return state
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { PanelStore, PanelTab } from './types'
export const usePanelStore = create<PanelStore>()(
devtools(
persist(
(set) => ({
isOpen: false,
activeTab: 'console',
togglePanel: () => {
set((state) => ({ isOpen: !state.isOpen }))
},
setActiveTab: (tab: PanelTab) => {
set({ activeTab: tab })
},
}),
{
name: 'panel-store',
}
)
)
)
+8
View File
@@ -0,0 +1,8 @@
export type PanelTab = 'console' | 'variables'
export interface PanelStore {
isOpen: boolean
activeTab: PanelTab
togglePanel: () => void
setActiveTab: (tab: PanelTab) => void
}
+378
View File
@@ -0,0 +1,378 @@
import { create } from 'zustand'
import { createLogger } from '@/lib/logs/console-logger'
import { devtools, persist } from 'zustand/middleware'
import { Variable, VariablesStore } from './types'
import { API_ENDPOINTS } from '@/stores/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Variables Store')
const SAVE_DEBOUNCE_DELAY = 500 // 500ms debounce delay
// Map to store debounce timers for each workflow
const saveTimers = new Map<string, NodeJS.Timeout>()
// Track which workflows have already been loaded
const loadedWorkflows = new Set<string>()
export const useVariablesStore = create<VariablesStore>()(
devtools(
persist(
(set, get) => ({
variables: {},
isLoading: false,
error: null,
isEditing: null,
addVariable: (variable) => {
const id = crypto.randomUUID()
// Get variables for this workflow
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
// Auto-generate variable name if not provided or it's a default pattern name
if (!variable.name || /^variable\d+$/.test(variable.name)) {
// Find the highest existing Variable N number
const existingNumbers = workflowVariables
.map(v => {
const match = v.name.match(/^variable(\d+)$/)
return match ? parseInt(match[1]) : 0
})
.filter(n => !isNaN(n))
// Set new number to max + 1, or 1 if none exist
const nextNumber = existingNumbers.length > 0
? Math.max(...existingNumbers) + 1
: 1
variable.name = `variable${nextNumber}`
}
// Ensure name uniqueness within the workflow
let uniqueName = variable.name
let nameIndex = 1
// Check if name already exists in this workflow
while (workflowVariables.some(v => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
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}"`
}
}
set((state) => ({
variables: {
...state.variables,
[id]: {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variableValue,
},
},
}))
// Auto-save to DB
get().saveVariables(variable.workflowId)
return id
},
updateVariable: (id, update) => {
set((state) => {
if (!state.variables[id]) return state
// If name is being updated, ensure it's unique
if (update.name) {
const workflowId = state.variables[id].workflowId
const workflowVariables = Object.values(state.variables).filter(
v => v.workflowId === workflowId && v.id !== id
)
let uniqueName = update.name
let nameIndex = 1
// Check if name already exists in this workflow
while (workflowVariables.some(v => v.name === uniqueName)) {
uniqueName = `${update.name} (${nameIndex})`
nameIndex++
}
// Update with unique name
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}"` }
}
}
const updated = {
...state.variables,
[id]: {
...state.variables[id],
...update,
},
}
// Debounced auto-save to DB
const workflowId = state.variables[id].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 { variables: updated }
})
},
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const workflowId = state.variables[id].workflowId
const { [id]: _, ...rest } = state.variables
// Auto-save to DB - no debounce for deletion
setTimeout(() => get().saveVariables(workflowId), 0)
return { variables: rest }
})
},
duplicateVariable: (id) => {
const state = get()
if (!state.variables[id]) return ''
const variable = state.variables[id]
const newId = crypto.randomUUID()
// Ensure the duplicated name is unique
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
let baseName = `${variable.name} (copy)`
let uniqueName = baseName
let nameIndex = 1
// Check if name already exists in this workflow
while (workflowVariables.some(v => v.name === uniqueName)) {
uniqueName = `${baseName} (${nameIndex})`
nameIndex++
}
set((state) => ({
variables: {
...state.variables,
[newId]: {
id: newId,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value,
},
},
}))
// Auto-save to DB
get().saveVariables(variable.workflowId)
return newId
},
loadVariables: async (workflowId) => {
// Skip if already loaded to prevent redundant API calls
if (loadedWorkflows.has(workflowId)) return
try {
set({ isLoading: true, error: null })
const response = await fetch(`${API_ENDPOINTS.WORKFLOW_VARIABLES}/${workflowId}`)
// Handle 404 workflow not found gracefully
if (response.status === 404) {
logger.info(`No variables found for workflow ${workflowId}, initializing empty set`)
set((state) => {
// Keep variables from other workflows
const otherVariables = Object.values(state.variables).reduce((acc, variable) => {
if (variable.workflowId !== workflowId) {
acc[variable.id] = variable
}
return acc
}, {} as Record<string, Variable>)
// Mark this workflow as loaded to prevent further attempts
loadedWorkflows.add(workflowId)
return {
variables: otherVariables,
isLoading: false,
}
})
return
}
if (!response.ok) {
throw new Error(`Failed to load workflow variables: ${response.statusText}`)
}
const { data } = await response.json()
if (data && typeof data === 'object') {
set((state) => {
// Merge with existing variables from other workflows
const otherVariables = Object.values(state.variables).reduce((acc, variable) => {
if (variable.workflowId !== workflowId) {
acc[variable.id] = variable
}
return acc
}, {} as Record<string, Variable>)
// Mark this workflow as loaded
loadedWorkflows.add(workflowId)
return {
variables: { ...otherVariables, ...data },
isLoading: false,
}
})
} else {
set((state) => {
// Keep variables from other workflows
const otherVariables = Object.values(state.variables).reduce((acc, variable) => {
if (variable.workflowId !== workflowId) {
acc[variable.id] = variable
}
return acc
}, {} as Record<string, Variable>)
// Mark this workflow as loaded
loadedWorkflows.add(workflowId)
return {
variables: otherVariables,
isLoading: false,
}
})
}
} catch (error) {
logger.error('Error loading workflow variables:', { error, workflowId })
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false,
})
}
},
saveVariables: async (workflowId) => {
try {
// Skip if workflow doesn't exist in the registry
const workflowExists = useWorkflowRegistry.getState().workflows[workflowId]
if (!workflowExists) {
logger.info(`Skipping variable save for non-existent workflow: ${workflowId}`)
return
}
set({ isLoading: true, error: null })
// Get only variables for this workflow
const workflowVariables = Object.values(get().variables).filter(
(variable) => variable.workflowId === workflowId
)
// Send to DB
const response = await fetch(`${API_ENDPOINTS.WORKFLOW_VARIABLES}/${workflowId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
variables: workflowVariables,
}),
})
// Handle 404 workflow not found gracefully
if (response.status === 404) {
logger.info(`Cannot save variables - workflow ${workflowId} not found in database yet`)
// Reset loading state but don't treat as error
set({ isLoading: false })
return
}
if (!response.ok) {
throw new Error(`Failed to save workflow variables: ${response.statusText}`)
}
set({ isLoading: false })
} catch (error) {
logger.error('Error saving workflow variables:', { error, workflowId })
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false,
})
// Reload from DB to ensure consistency
// Reset tracking to force a reload
loadedWorkflows.delete(workflowId)
get().loadVariables(workflowId)
}
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
},
// Reset the loaded workflow tracking
resetLoaded: () => {
loadedWorkflows.clear()
},
}),
{
name: 'variables-store',
}
)
)
)
+52
View File
@@ -0,0 +1,52 @@
export type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array'
/**
* Represents a workflow variable with workflow-specific naming
* Variable names must be unique within each workflow
*/
export interface Variable {
id: string
workflowId: string
name: string // Must be unique per workflow
type: VariableType
value: any
}
export interface VariablesStore {
variables: Record<string, Variable>
isLoading: boolean
error: string | null
isEditing: string | null
/**
* Adds a new variable with automatic name uniqueness validation
* If a variable with the same name exists, it will be suffixed with a number
*/
addVariable: (variable: Omit<Variable, 'id'>) => string
/**
* Updates a variable, ensuring name remains unique within the workflow
* If an updated name conflicts with existing ones, a numbered suffix is added
*/
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
/**
* Duplicates a variable with a "(copy)" suffix, ensuring name uniqueness
*/
duplicateVariable: (id: string) => string
loadVariables: (workflowId: string) => Promise<void>
saveVariables: (workflowId: string) => Promise<void>
/**
* Returns all variables for a specific workflow
*/
getVariablesByWorkflowId: (workflowId: string) => Variable[]
/**
* Resets tracking of loaded workflows
*/
resetLoaded: () => void
}
+56 -7
View File
@@ -12,6 +12,10 @@ import { BlockState } from './workflow/types'
const logger = createLogger('Workflows Sync')
// Add debounce utility
let syncDebounceTimer: NodeJS.Timeout | null = null;
const DEBOUNCE_DELAY = 500; // 500ms delay
// Flag to prevent immediate sync back to DB after loading from DB
let isLoadingFromDB = false
let loadingFromDBToken: string | null = null
@@ -177,8 +181,10 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
const workflowCount = Object.keys(registryWorkflows).length
logger.info(`DB loading complete. Workflows in registry: ${workflowCount}`)
// Trigger one final sync to ensure consistency
if (workflowCount > 0) {
// Only trigger a final sync if necessary (don't do this for normal loads)
// This helps reduce unnecessary POST requests
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (workflowCount > 0 && activeWorkflowId && activeDBSyncNeeded()) {
// Small delay for state to fully settle before allowing syncs
setTimeout(() => {
workflowSync.sync()
@@ -188,8 +194,26 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
}
}
// Syncs workflows to the database
export const workflowSync = createSingletonSyncManager('workflow-sync', () => ({
// Helper to determine if an active DB sync is actually needed
function activeDBSyncNeeded(): boolean {
// In most cases after initial load, we don't need to sync back to DB
// Only sync if we have detected a change that needs to be persisted
const lastSynced = localStorage.getItem('last_db_sync_timestamp')
const currentTime = Date.now()
if (!lastSynced) {
// First sync - record it and return true
localStorage.setItem('last_db_sync_timestamp', currentTime.toString())
return true
}
// Add additional checks here if needed for specific workflow changes
// For now, we'll simply avoid the automatic sync after load
return false
}
// Create the basic sync configuration
const workflowSyncConfig = {
endpoint: API_ENDPOINTS.WORKFLOW,
preparePayload: () => {
if (typeof window === 'undefined') return {}
@@ -222,10 +246,35 @@ export const workflowSync = createSingletonSyncManager('workflow-sync', () => ({
workflows: workflowsData,
}
},
method: 'POST',
method: 'POST' as const,
syncOnInterval: true,
syncOnExit: true,
onSyncSuccess: async (data) => {
onSyncSuccess: async () => {
logger.info('Workflows synced to DB successfully')
},
}))
};
// Create the sync manager
const baseWorkflowSync = createSingletonSyncManager('workflow-sync', () => workflowSyncConfig);
// Create a debounced version of the sync manager
export const workflowSync = {
...baseWorkflowSync,
sync: () => {
// Clear any existing timeout
if (syncDebounceTimer) {
clearTimeout(syncDebounceTimer);
}
// Set new timeout
syncDebounceTimer = setTimeout(() => {
// Perform the sync
baseWorkflowSync.sync();
// Update the last sync timestamp
if (typeof window !== 'undefined') {
localStorage.setItem('last_db_sync_timestamp', Date.now().toString());
}
}, DEBOUNCE_DELAY);
}
};