mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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
-1
@@ -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>
|
||||
)
|
||||
}
|
||||
+60
-43
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow" ADD COLUMN "variables" json DEFAULT '{}';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,13 @@
|
||||
"when": 1742850849852,
|
||||
"tag": "0021_shocking_korath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1742889909342,
|
||||
"tag": "0022_gray_galactus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
export type PanelTab = 'console' | 'variables'
|
||||
|
||||
export interface PanelStore {
|
||||
isOpen: boolean
|
||||
activeTab: PanelTab
|
||||
togglePanel: () => void
|
||||
setActiveTab: (tab: PanelTab) => void
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user