fix: resolve TypeScript build errors for calendar and workflow

- CalendarEventShapeUtil: Fix destructuring (w,h are in props, not shape)
- CalendarPanel: Prefix unused variables with underscore
- YearViewPanel: Prefix unused variables with underscore
- Add missing workflow files (WorkflowPropagator, WorkflowBlockShape, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-23 11:20:15 -05:00
parent fd0196c6a2
commit 9167342d98
14 changed files with 5830 additions and 6 deletions

View File

@ -45,9 +45,9 @@ const isToday = (date: Date) => {
}
export function CalendarPanel({
onClose,
onClose: _onClose,
onEventSelect,
shapeMode = false,
shapeMode: _shapeMode = false,
initialView = "month",
initialDate,
}: CalendarPanelProps) {
@ -255,7 +255,7 @@ export function CalendarPanel({
{date.getDate()}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px" }}>
{dayEvents.slice(0, 3).map((event, i) => (
{dayEvents.slice(0, 3).map((event) => (
<div
key={event.id}
style={{

View File

@ -42,9 +42,9 @@ const SHORT_MONTH_NAMES = [
]
export const YearViewPanel: React.FC<YearViewPanelProps> = ({
onClose,
onClose: _onClose,
onMonthSelect,
shapeMode = false,
shapeMode: _shapeMode = false,
initialYear,
}) => {
const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear())

View File

@ -0,0 +1,497 @@
/**
* WorkflowPalette
*
* A sidebar panel displaying available workflow blocks organized by category.
* Users can click on blocks to enter placement mode or drag them onto the canvas.
*/
import React, { useState, useCallback, useMemo } from 'react'
import { useEditor } from 'tldraw'
import {
getAllBlockDefinitions,
getBlocksByCategory,
} from '@/lib/workflow/blockRegistry'
import {
BlockCategory,
BlockDefinition,
CATEGORY_INFO,
} from '@/lib/workflow/types'
import { setWorkflowBlockType } from '@/tools/WorkflowBlockTool'
import { executeWorkflow, resetWorkflow } from '@/lib/workflow/executor'
import { setAutoExecute, isAutoExecuteEnabled } from '@/propagators/WorkflowPropagator'
// =============================================================================
// Types
// =============================================================================
interface WorkflowPaletteProps {
isOpen: boolean
onClose: () => void
}
// =============================================================================
// Block Card Component
// =============================================================================
interface BlockCardProps {
definition: BlockDefinition
onSelect: (blockType: string) => void
}
const BlockCard: React.FC<BlockCardProps> = ({ definition, onSelect }) => {
const [isHovered, setIsHovered] = useState(false)
const categoryInfo = CATEGORY_INFO[definition.category]
return (
<div
onClick={() => onSelect(definition.type)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
padding: '10px 12px',
backgroundColor: isHovered ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
borderRadius: 6,
cursor: 'pointer',
transition: 'background-color 0.15s ease',
display: 'flex',
alignItems: 'flex-start',
gap: 10,
borderLeft: `3px solid ${categoryInfo.color}`,
marginBottom: 4,
}}
>
<span style={{ fontSize: 18, lineHeight: 1 }}>{definition.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: '#1f2937',
marginBottom: 2,
}}
>
{definition.name}
</div>
<div
style={{
fontSize: 11,
color: '#6b7280',
lineHeight: 1.4,
}}
>
{definition.description}
</div>
<div
style={{
display: 'flex',
gap: 8,
marginTop: 6,
fontSize: 10,
color: '#9ca3af',
}}
>
<span>{definition.inputs.length} inputs</span>
<span>{definition.outputs.length} outputs</span>
</div>
</div>
</div>
)
}
// =============================================================================
// Category Section Component
// =============================================================================
interface CategorySectionProps {
category: BlockCategory
blocks: BlockDefinition[]
isExpanded: boolean
onToggle: () => void
onSelectBlock: (blockType: string) => void
}
const CategorySection: React.FC<CategorySectionProps> = ({
category,
blocks,
isExpanded,
onToggle,
onSelectBlock,
}) => {
const categoryInfo = CATEGORY_INFO[category]
return (
<div style={{ marginBottom: 8 }}>
<div
onClick={onToggle}
style={{
padding: '8px 12px',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
userSelect: 'none',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: categoryInfo.color,
}}
/>
<span
style={{
fontSize: 12,
fontWeight: 600,
color: '#374151',
flex: 1,
}}
>
{categoryInfo.label}
</span>
<span
style={{
fontSize: 10,
color: '#9ca3af',
marginRight: 4,
}}
>
{blocks.length}
</span>
<span
style={{
fontSize: 12,
color: '#9ca3af',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.15s ease',
}}
>
</span>
</div>
{isExpanded && (
<div style={{ marginTop: 4, paddingLeft: 4 }}>
{blocks.map((block) => (
<BlockCard
key={block.type}
definition={block}
onSelect={onSelectBlock}
/>
))}
</div>
)}
</div>
)
}
// =============================================================================
// Main Palette Component
// =============================================================================
export const WorkflowPalette: React.FC<WorkflowPaletteProps> = ({
isOpen,
onClose,
}) => {
const editor = useEditor()
const [searchQuery, setSearchQuery] = useState('')
const [expandedCategories, setExpandedCategories] = useState<Set<BlockCategory>>(
new Set(['trigger', 'action'])
)
const [autoExecute, setAutoExecuteState] = useState(isAutoExecuteEnabled())
const [isExecuting, setIsExecuting] = useState(false)
// Get all blocks grouped by category
const blocksByCategory = useMemo(() => {
const categories: BlockCategory[] = ['trigger', 'action', 'condition', 'transformer', 'ai', 'output']
const result: Record<BlockCategory, BlockDefinition[]> = {} as any
for (const category of categories) {
const blocks = getBlocksByCategory(category)
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase()
result[category] = blocks.filter(
(b) =>
b.name.toLowerCase().includes(query) ||
b.description.toLowerCase().includes(query) ||
b.type.toLowerCase().includes(query)
)
} else {
result[category] = blocks
}
}
return result
}, [searchQuery])
// Toggle category expansion
const toggleCategory = useCallback((category: BlockCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}, [])
// Handle block selection
const handleSelectBlock = useCallback(
(blockType: string) => {
setWorkflowBlockType(blockType)
editor.setCurrentTool('WorkflowBlock')
},
[editor]
)
// Handle run workflow
const handleRunWorkflow = useCallback(async () => {
setIsExecuting(true)
try {
await executeWorkflow(editor, {
onProgress: (completed, total) => {
console.log(`Workflow progress: ${completed}/${total}`)
},
})
} finally {
setIsExecuting(false)
}
}, [editor])
// Handle reset workflow
const handleResetWorkflow = useCallback(() => {
resetWorkflow(editor)
}, [editor])
// Toggle auto-execute
const handleToggleAutoExecute = useCallback(() => {
const newValue = !autoExecute
setAutoExecuteState(newValue)
setAutoExecute(newValue)
}, [autoExecute])
if (!isOpen) return null
const categories: BlockCategory[] = ['trigger', 'action', 'condition', 'transformer', 'ai', 'output']
return (
<div
style={{
position: 'fixed',
top: 60,
left: 10,
width: 280,
maxHeight: 'calc(100vh - 120px)',
backgroundColor: 'white',
borderRadius: 12,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
border: '1px solid #e5e7eb',
display: 'flex',
flexDirection: 'column',
zIndex: 1000,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
{/* Header */}
<div
style={{
padding: '14px 16px',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 16 }}></span>
<span
style={{
fontSize: 14,
fontWeight: 600,
color: '#1f2937',
}}
>
Workflow Blocks
</span>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: 18,
color: '#9ca3af',
cursor: 'pointer',
padding: 4,
lineHeight: 1,
}}
>
×
</button>
</div>
{/* Search */}
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e5e7eb' }}>
<input
type="text"
placeholder="Search blocks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
fontSize: 13,
border: '1px solid #e5e7eb',
borderRadius: 6,
outline: 'none',
backgroundColor: '#f9fafb',
}}
/>
</div>
{/* Execution Controls */}
<div
style={{
padding: '10px 12px',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
gap: 8,
}}
>
<button
onClick={handleRunWorkflow}
disabled={isExecuting}
style={{
flex: 1,
padding: '8px 12px',
backgroundColor: isExecuting ? '#9ca3af' : '#10b981',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
cursor: isExecuting ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
}}
>
{isExecuting ? (
<>
<span style={{ animation: 'spin 1s linear infinite' }}></span>
Running...
</>
) : (
<> Run Workflow</>
)}
</button>
<button
onClick={handleResetWorkflow}
style={{
padding: '8px 12px',
backgroundColor: '#f3f4f6',
color: '#374151',
border: '1px solid #e5e7eb',
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
}}
title="Reset all blocks"
>
</button>
</div>
{/* Auto-execute Toggle */}
<div
style={{
padding: '8px 12px',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span style={{ fontSize: 12, color: '#6b7280' }}>
Real-time propagation
</span>
<button
onClick={handleToggleAutoExecute}
style={{
width: 40,
height: 22,
borderRadius: 11,
border: 'none',
backgroundColor: autoExecute ? '#10b981' : '#d1d5db',
cursor: 'pointer',
position: 'relative',
transition: 'background-color 0.15s ease',
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: autoExecute ? 20 : 2,
width: 18,
height: 18,
borderRadius: '50%',
backgroundColor: 'white',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
transition: 'left 0.15s ease',
}}
/>
</button>
</div>
{/* Block Categories */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px',
}}
>
{categories.map((category) => {
const blocks = blocksByCategory[category]
if (blocks.length === 0 && searchQuery) return null
return (
<CategorySection
key={category}
category={category}
blocks={blocks}
isExpanded={expandedCategories.has(category)}
onToggle={() => toggleCategory(category)}
onSelectBlock={handleSelectBlock}
/>
)
})}
</div>
{/* Footer */}
<div
style={{
padding: '10px 12px',
borderTop: '1px solid #e5e7eb',
fontSize: 11,
color: '#9ca3af',
textAlign: 'center',
}}
>
Click a block, then click on canvas to place
</div>
</div>
)
}
export default WorkflowPalette

589
src/css/workflow.css Normal file
View File

@ -0,0 +1,589 @@
/**
* Workflow Builder Styles
*
* Styles for the Flowy-like workflow builder system including
* workflow blocks, ports, palette, and execution states.
*/
/* =============================================================================
Workflow Block Base Styles
============================================================================= */
.workflow-block {
position: relative;
border-radius: 8px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease, transform 0.1s ease;
}
.workflow-block:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.workflow-block.selected {
box-shadow: 0 0 0 2px var(--workflow-category-color, #6366f1),
0 4px 16px rgba(0, 0, 0, 0.15);
}
.workflow-block.executing {
animation: workflow-pulse 1.5s ease-in-out infinite;
}
@keyframes workflow-pulse {
0%, 100% {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.4),
0 4px 16px rgba(0, 0, 0, 0.15);
}
50% {
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.2),
0 4px 16px rgba(0, 0, 0, 0.15);
}
}
/* =============================================================================
Block Header
============================================================================= */
.workflow-block-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e5e7eb;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.02) 0%, transparent 100%);
border-radius: 8px 8px 0 0;
}
.workflow-block-icon {
font-size: 16px;
line-height: 1;
}
.workflow-block-title {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workflow-block-category {
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* =============================================================================
Port Styles
============================================================================= */
.workflow-port {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid;
background: white;
cursor: crosshair;
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
z-index: 10;
}
.workflow-port:hover {
transform: scale(1.3);
}
.workflow-port.connected {
background-color: currentColor;
}
.workflow-port.compatible {
box-shadow: 0 0 8px currentColor;
}
.workflow-port.incompatible {
opacity: 0.3;
cursor: not-allowed;
}
/* Port type colors */
.workflow-port.type-text { color: #3b82f6; }
.workflow-port.type-number { color: #10b981; }
.workflow-port.type-boolean { color: #f59e0b; }
.workflow-port.type-object { color: #8b5cf6; }
.workflow-port.type-array { color: #06b6d4; }
.workflow-port.type-any { color: #6b7280; }
.workflow-port.type-file { color: #ec4899; }
.workflow-port.type-image { color: #f97316; }
/* Port labels */
.workflow-port-label {
position: absolute;
font-size: 11px;
color: #4b5563;
white-space: nowrap;
pointer-events: none;
display: flex;
align-items: center;
gap: 4px;
}
.workflow-port-label.input {
left: 16px;
}
.workflow-port-label.output {
right: 16px;
text-align: right;
}
.workflow-port-label-dot {
width: 6px;
height: 6px;
border-radius: 50%;
opacity: 0.6;
}
/* =============================================================================
Execution States
============================================================================= */
.workflow-execution-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.workflow-execution-badge.idle {
background: transparent;
color: #6b7280;
}
.workflow-execution-badge.running {
background: #fef3c7;
border: 1px solid #f59e0b;
color: #92400e;
}
.workflow-execution-badge.success {
background: #d1fae5;
border: 1px solid #10b981;
color: #065f46;
}
.workflow-execution-badge.error {
background: #fee2e2;
border: 1px solid #ef4444;
color: #991b1b;
}
/* =============================================================================
Workflow Palette
============================================================================= */
.workflow-palette {
position: fixed;
top: 60px;
left: 10px;
width: 280px;
max-height: calc(100vh - 120px);
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
border: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
z-index: 1000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.workflow-palette-header {
padding: 14px 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
}
.workflow-palette-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.workflow-palette-close {
background: none;
border: none;
font-size: 18px;
color: #9ca3af;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.workflow-palette-close:hover {
color: #4b5563;
}
.workflow-palette-search {
padding: 10px 12px;
border-bottom: 1px solid #e5e7eb;
}
.workflow-palette-search input {
width: 100%;
padding: 8px 12px;
font-size: 13px;
border: 1px solid #e5e7eb;
border-radius: 6px;
outline: none;
background: #f9fafb;
}
.workflow-palette-search input:focus {
border-color: #6366f1;
background: white;
}
.workflow-palette-controls {
padding: 10px 12px;
border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 8px;
}
.workflow-palette-run-btn {
flex: 1;
padding: 8px 12px;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background-color 0.15s ease;
}
.workflow-palette-run-btn:hover {
background: #059669;
}
.workflow-palette-run-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.workflow-palette-reset-btn {
padding: 8px 12px;
background: #f3f4f6;
color: #374151;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.workflow-palette-reset-btn:hover {
background: #e5e7eb;
}
.workflow-palette-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.workflow-palette-footer {
padding: 10px 12px;
border-top: 1px solid #e5e7eb;
font-size: 11px;
color: #9ca3af;
text-align: center;
}
/* =============================================================================
Category Sections
============================================================================= */
.workflow-category {
margin-bottom: 8px;
}
.workflow-category-header {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.02);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.workflow-category-header:hover {
background: rgba(0, 0, 0, 0.04);
}
.workflow-category-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.workflow-category-label {
font-size: 12px;
font-weight: 600;
color: #374151;
flex: 1;
}
.workflow-category-count {
font-size: 10px;
color: #9ca3af;
margin-right: 4px;
}
.workflow-category-arrow {
font-size: 12px;
color: #9ca3af;
transition: transform 0.15s ease;
}
.workflow-category.expanded .workflow-category-arrow {
transform: rotate(90deg);
}
.workflow-category-blocks {
margin-top: 4px;
padding-left: 4px;
}
/* =============================================================================
Block Cards in Palette
============================================================================= */
.workflow-block-card {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 10px;
border-left: 3px solid;
margin-bottom: 4px;
transition: background-color 0.15s ease;
}
.workflow-block-card:hover {
background: rgba(0, 0, 0, 0.05);
}
.workflow-block-card-icon {
font-size: 18px;
line-height: 1;
}
.workflow-block-card-content {
flex: 1;
min-width: 0;
}
.workflow-block-card-name {
font-size: 13px;
font-weight: 500;
color: #1f2937;
margin-bottom: 2px;
}
.workflow-block-card-description {
font-size: 11px;
color: #6b7280;
line-height: 1.4;
}
.workflow-block-card-meta {
display: flex;
gap: 8px;
margin-top: 6px;
font-size: 10px;
color: #9ca3af;
}
/* =============================================================================
Connection Arrows
============================================================================= */
.workflow-arrow {
stroke: #374151;
stroke-width: 2;
fill: none;
}
.workflow-arrow.valid {
stroke: #10b981;
}
.workflow-arrow.invalid {
stroke: #f59e0b;
stroke-dasharray: 4 2;
}
.workflow-arrow.dragging {
stroke: #6366f1;
stroke-dasharray: 6 3;
animation: workflow-arrow-dash 0.5s linear infinite;
}
@keyframes workflow-arrow-dash {
to {
stroke-dashoffset: -9;
}
}
/* =============================================================================
Toggle Switch
============================================================================= */
.workflow-toggle {
width: 40px;
height: 22px;
border-radius: 11px;
border: none;
background: #d1d5db;
cursor: pointer;
position: relative;
transition: background-color 0.15s ease;
}
.workflow-toggle.active {
background: #10b981;
}
.workflow-toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: left 0.15s ease;
}
.workflow-toggle.active .workflow-toggle-knob {
left: 20px;
}
/* =============================================================================
Animation Keyframes
============================================================================= */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* =============================================================================
Dark Mode
============================================================================= */
.dark .workflow-block {
background: #1f2937;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.dark .workflow-block-header {
border-bottom-color: #374151;
}
.dark .workflow-block-title {
color: #f3f4f6;
}
.dark .workflow-port {
background: #1f2937;
}
.dark .workflow-port.connected {
background-color: currentColor;
}
.dark .workflow-port-label {
color: #9ca3af;
}
.dark .workflow-palette {
background: #1f2937;
border-color: #374151;
}
.dark .workflow-palette-header {
border-bottom-color: #374151;
}
.dark .workflow-palette-title {
color: #f3f4f6;
}
.dark .workflow-palette-search input {
background: #111827;
border-color: #374151;
color: #f3f4f6;
}
.dark .workflow-palette-controls {
border-bottom-color: #374151;
}
.dark .workflow-category-header {
background: rgba(255, 255, 255, 0.05);
}
.dark .workflow-category-header:hover {
background: rgba(255, 255, 255, 0.08);
}
.dark .workflow-category-label {
color: #e5e7eb;
}
.dark .workflow-block-card:hover {
background: rgba(255, 255, 255, 0.08);
}
.dark .workflow-block-card-name {
color: #f3f4f6;
}
.dark .workflow-block-card-description {
color: #9ca3af;
}
.dark .workflow-palette-footer {
border-top-color: #374151;
}

View File

@ -0,0 +1,881 @@
/**
* Block Registry
*
* Defines all available workflow blocks with their ports, configuration,
* and metadata. Blocks are organized by category for the palette UI.
*/
import {
BlockDefinition,
BlockCategory,
InputPort,
OutputPort,
} from './types'
// =============================================================================
// Block Registry Storage
// =============================================================================
const BLOCK_REGISTRY: Map<string, BlockDefinition> = new Map()
// =============================================================================
// Helper Functions for Port Creation
// =============================================================================
function input(
id: string,
name: string,
type: 'text' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'file' | 'image',
options: Partial<Omit<InputPort, 'id' | 'name' | 'type' | 'direction'>> = {}
): InputPort {
return {
id,
name,
type,
direction: 'input',
required: options.required ?? false,
accepts: options.accepts ?? [type, 'any'],
description: options.description,
defaultValue: options.defaultValue,
}
}
function output(
id: string,
name: string,
type: 'text' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'file' | 'image',
options: Partial<Omit<OutputPort, 'id' | 'name' | 'type' | 'direction' | 'produces'>> = {}
): OutputPort {
return {
id,
name,
type,
direction: 'output',
produces: type,
required: false,
description: options.description,
defaultValue: options.defaultValue,
}
}
// =============================================================================
// TRIGGER BLOCKS
// =============================================================================
const ManualTrigger: BlockDefinition = {
type: 'trigger.manual',
category: 'trigger',
name: 'Manual Trigger',
description: 'Start workflow with a button click',
icon: '▶️',
color: '#f59e0b',
inputs: [],
outputs: [
output('timestamp', 'Timestamp', 'number', {
description: 'Unix timestamp when triggered',
}),
output('trigger', 'Trigger Data', 'object', {
description: 'Metadata about the trigger event',
}),
],
executor: 'trigger.manual',
}
const ScheduleTrigger: BlockDefinition = {
type: 'trigger.schedule',
category: 'trigger',
name: 'Schedule',
description: 'Run on a schedule (cron expression)',
icon: '⏰',
color: '#f59e0b',
inputs: [],
outputs: [
output('timestamp', 'Timestamp', 'number'),
output('scheduledTime', 'Scheduled Time', 'number'),
],
configSchema: {
type: 'object',
properties: {
cron: {
type: 'string',
description: 'Cron expression (e.g., "0 * * * *" for every hour)',
default: '0 * * * *',
},
timezone: {
type: 'string',
description: 'Timezone for schedule',
default: 'UTC',
},
},
},
defaultConfig: {
cron: '0 * * * *',
timezone: 'UTC',
},
executor: 'trigger.schedule',
}
const WebhookTrigger: BlockDefinition = {
type: 'trigger.webhook',
category: 'trigger',
name: 'Webhook',
description: 'Trigger from external HTTP request',
icon: '🌐',
color: '#f59e0b',
inputs: [],
outputs: [
output('body', 'Request Body', 'object'),
output('headers', 'Headers', 'object'),
output('method', 'Method', 'text'),
output('url', 'URL', 'text'),
],
executor: 'trigger.webhook',
}
// =============================================================================
// ACTION BLOCKS
// =============================================================================
const HttpRequest: BlockDefinition = {
type: 'action.http',
category: 'action',
name: 'HTTP Request',
description: 'Make HTTP API calls',
icon: '🌐',
color: '#3b82f6',
inputs: [
input('url', 'URL', 'text', { required: true, description: 'Request URL' }),
input('body', 'Body', 'object', { description: 'Request body (for POST/PUT)' }),
input('headers', 'Headers', 'object', { description: 'Additional headers' }),
],
outputs: [
output('response', 'Response', 'object', { description: 'Parsed response body' }),
output('status', 'Status', 'number', { description: 'HTTP status code' }),
output('headers', 'Response Headers', 'object'),
],
configSchema: {
type: 'object',
properties: {
method: {
type: 'string',
enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
default: 'GET',
},
contentType: {
type: 'string',
enum: ['application/json', 'application/x-www-form-urlencoded', 'text/plain'],
default: 'application/json',
},
timeout: {
type: 'number',
description: 'Request timeout in milliseconds',
default: 30000,
},
},
},
defaultConfig: {
method: 'GET',
contentType: 'application/json',
timeout: 30000,
},
executor: 'action.http',
}
const CreateShape: BlockDefinition = {
type: 'action.create-shape',
category: 'action',
name: 'Create Shape',
description: 'Create a new shape on the canvas',
icon: '📐',
color: '#3b82f6',
inputs: [
input('type', 'Shape Type', 'text', { required: true }),
input('x', 'X Position', 'number'),
input('y', 'Y Position', 'number'),
input('props', 'Properties', 'object'),
],
outputs: [
output('shapeId', 'Shape ID', 'text'),
output('shape', 'Shape', 'object'),
],
executor: 'action.create-shape',
}
const UpdateShape: BlockDefinition = {
type: 'action.update-shape',
category: 'action',
name: 'Update Shape',
description: 'Update properties of an existing shape',
icon: '✏️',
color: '#3b82f6',
inputs: [
input('shapeId', 'Shape ID', 'text', { required: true }),
input('props', 'Properties', 'object', { required: true }),
],
outputs: [
output('success', 'Success', 'boolean'),
output('shape', 'Updated Shape', 'object'),
],
executor: 'action.update-shape',
}
const Delay: BlockDefinition = {
type: 'action.delay',
category: 'action',
name: 'Delay',
description: 'Wait for a specified duration',
icon: '⏳',
color: '#3b82f6',
inputs: [
input('input', 'Pass Through', 'any', { description: 'Data to pass through after delay' }),
],
outputs: [
output('output', 'Output', 'any'),
],
configSchema: {
type: 'object',
properties: {
duration: {
type: 'number',
description: 'Delay in milliseconds',
default: 1000,
},
},
},
defaultConfig: {
duration: 1000,
},
executor: 'action.delay',
}
// =============================================================================
// CONDITION BLOCKS
// =============================================================================
const IfCondition: BlockDefinition = {
type: 'condition.if',
category: 'condition',
name: 'If/Else',
description: 'Branch based on a boolean condition',
icon: '❓',
color: '#8b5cf6',
inputs: [
input('condition', 'Condition', 'boolean', { required: true }),
input('value', 'Value', 'any', { required: true, description: 'Data to route' }),
],
outputs: [
output('true', 'If True', 'any', { description: 'Output when condition is true' }),
output('false', 'If False', 'any', { description: 'Output when condition is false' }),
],
executor: 'condition.if',
}
const SwitchCondition: BlockDefinition = {
type: 'condition.switch',
category: 'condition',
name: 'Switch',
description: 'Route based on value matching',
icon: '🔀',
color: '#8b5cf6',
inputs: [
input('value', 'Value', 'any', { required: true }),
input('data', 'Data', 'any', { description: 'Data to route' }),
],
outputs: [
output('case1', 'Case 1', 'any'),
output('case2', 'Case 2', 'any'),
output('case3', 'Case 3', 'any'),
output('default', 'Default', 'any'),
],
configSchema: {
type: 'object',
properties: {
cases: {
type: 'array',
items: { type: 'string' },
default: ['value1', 'value2', 'value3'],
},
},
},
defaultConfig: {
cases: ['value1', 'value2', 'value3'],
},
executor: 'condition.switch',
}
const Compare: BlockDefinition = {
type: 'condition.compare',
category: 'condition',
name: 'Compare',
description: 'Compare two values',
icon: '⚖️',
color: '#8b5cf6',
inputs: [
input('left', 'Left Value', 'any', { required: true }),
input('right', 'Right Value', 'any', { required: true }),
],
outputs: [
output('result', 'Result', 'boolean'),
],
configSchema: {
type: 'object',
properties: {
operator: {
type: 'string',
enum: ['==', '!=', '>', '<', '>=', '<=', 'contains', 'startsWith', 'endsWith'],
default: '==',
},
},
},
defaultConfig: {
operator: '==',
},
executor: 'condition.compare',
}
// =============================================================================
// TRANSFORMER BLOCKS
// =============================================================================
const JsonParse: BlockDefinition = {
type: 'transformer.json-parse',
category: 'transformer',
name: 'Parse JSON',
description: 'Parse JSON string to object',
icon: '📋',
color: '#10b981',
inputs: [
input('input', 'JSON String', 'text', { required: true }),
],
outputs: [
output('output', 'Object', 'object'),
],
executor: 'transformer.json-parse',
}
const JsonStringify: BlockDefinition = {
type: 'transformer.json-stringify',
category: 'transformer',
name: 'Stringify JSON',
description: 'Convert object to JSON string',
icon: '📝',
color: '#10b981',
inputs: [
input('input', 'Object', 'object', { required: true }),
],
outputs: [
output('output', 'JSON String', 'text'),
],
configSchema: {
type: 'object',
properties: {
pretty: { type: 'boolean', default: false },
indent: { type: 'number', default: 2 },
},
},
defaultConfig: { pretty: false, indent: 2 },
executor: 'transformer.json-stringify',
}
const CodeTransformer: BlockDefinition = {
type: 'transformer.code',
category: 'transformer',
name: 'JavaScript',
description: 'Run custom JavaScript code',
icon: '💻',
color: '#10b981',
inputs: [
input('input', 'Input', 'any', { required: true }),
input('context', 'Context', 'object', { description: 'Additional context data' }),
],
outputs: [
output('output', 'Output', 'any'),
],
configSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'JavaScript code. Use `input` variable. Return value becomes output.',
default: 'return input;',
},
},
},
defaultConfig: {
code: 'return input;',
},
executor: 'transformer.code',
}
const Template: BlockDefinition = {
type: 'transformer.template',
category: 'transformer',
name: 'Template',
description: 'String interpolation with variables',
icon: '📄',
color: '#10b981',
inputs: [
input('data', 'Data', 'object', { required: true }),
],
outputs: [
output('output', 'Text', 'text'),
],
configSchema: {
type: 'object',
properties: {
template: {
type: 'string',
description: 'Template string. Use {{key}} for interpolation.',
default: 'Hello, {{name}}!',
},
},
},
defaultConfig: {
template: 'Hello, {{name}}!',
},
executor: 'transformer.template',
}
const GetProperty: BlockDefinition = {
type: 'transformer.get-property',
category: 'transformer',
name: 'Get Property',
description: 'Extract a property from an object',
icon: '🔍',
color: '#10b981',
inputs: [
input('object', 'Object', 'object', { required: true }),
],
outputs: [
output('value', 'Value', 'any'),
],
configSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Property path (e.g., "data.user.name")',
default: '',
},
},
},
defaultConfig: { path: '' },
executor: 'transformer.get-property',
}
const SetProperty: BlockDefinition = {
type: 'transformer.set-property',
category: 'transformer',
name: 'Set Property',
description: 'Set a property on an object',
icon: '✏️',
color: '#10b981',
inputs: [
input('object', 'Object', 'object', { required: true }),
input('value', 'Value', 'any', { required: true }),
],
outputs: [
output('output', 'Object', 'object'),
],
configSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Property path to set',
default: '',
},
},
},
defaultConfig: { path: '' },
executor: 'transformer.set-property',
}
const ArrayMap: BlockDefinition = {
type: 'transformer.array-map',
category: 'transformer',
name: 'Map Array',
description: 'Transform each item in an array',
icon: '🗂️',
color: '#10b981',
inputs: [
input('array', 'Array', 'array', { required: true }),
],
outputs: [
output('output', 'Array', 'array'),
],
configSchema: {
type: 'object',
properties: {
expression: {
type: 'string',
description: 'JavaScript expression. Use `item` and `index` variables.',
default: 'item',
},
},
},
defaultConfig: { expression: 'item' },
executor: 'transformer.array-map',
}
const ArrayFilter: BlockDefinition = {
type: 'transformer.array-filter',
category: 'transformer',
name: 'Filter Array',
description: 'Filter array items by condition',
icon: '🔍',
color: '#10b981',
inputs: [
input('array', 'Array', 'array', { required: true }),
],
outputs: [
output('output', 'Array', 'array'),
],
configSchema: {
type: 'object',
properties: {
condition: {
type: 'string',
description: 'JavaScript condition. Use `item` and `index` variables.',
default: 'true',
},
},
},
defaultConfig: { condition: 'true' },
executor: 'transformer.array-filter',
}
// =============================================================================
// AI BLOCKS
// =============================================================================
const LLMPrompt: BlockDefinition = {
type: 'ai.llm',
category: 'ai',
name: 'LLM Prompt',
description: 'Send prompt to language model',
icon: '🤖',
color: '#ec4899',
inputs: [
input('prompt', 'Prompt', 'text', { required: true }),
input('context', 'Context', 'text', { description: 'Additional context' }),
input('systemPrompt', 'System Prompt', 'text', { description: 'System instructions' }),
],
outputs: [
output('response', 'Response', 'text'),
output('usage', 'Usage', 'object', { description: 'Token usage stats' }),
],
configSchema: {
type: 'object',
properties: {
model: {
type: 'string',
enum: ['llama3.1:8b', 'llama3.1:70b', 'claude-sonnet', 'gpt-4'],
default: 'llama3.1:8b',
},
temperature: {
type: 'number',
minimum: 0,
maximum: 2,
default: 0.7,
},
maxTokens: {
type: 'number',
default: 1000,
},
},
},
defaultConfig: {
model: 'llama3.1:8b',
temperature: 0.7,
maxTokens: 1000,
},
executor: 'ai.llm',
}
const ImageGen: BlockDefinition = {
type: 'ai.image-gen',
category: 'ai',
name: 'Image Generation',
description: 'Generate image from text prompt',
icon: '🎨',
color: '#ec4899',
inputs: [
input('prompt', 'Prompt', 'text', { required: true }),
input('negativePrompt', 'Negative Prompt', 'text'),
],
outputs: [
output('image', 'Image', 'image'),
output('url', 'Image URL', 'text'),
],
configSchema: {
type: 'object',
properties: {
model: {
type: 'string',
enum: ['SDXL', 'SD3', 'FLUX'],
default: 'SDXL',
},
width: { type: 'number', default: 512 },
height: { type: 'number', default: 512 },
steps: { type: 'number', default: 20 },
},
},
defaultConfig: {
model: 'SDXL',
width: 512,
height: 512,
steps: 20,
},
executor: 'ai.image-gen',
}
const TextToSpeech: BlockDefinition = {
type: 'ai.tts',
category: 'ai',
name: 'Text to Speech',
description: 'Convert text to audio',
icon: '🔊',
color: '#ec4899',
inputs: [
input('text', 'Text', 'text', { required: true }),
],
outputs: [
output('audio', 'Audio', 'file'),
output('url', 'Audio URL', 'text'),
],
configSchema: {
type: 'object',
properties: {
voice: {
type: 'string',
default: 'alloy',
},
speed: {
type: 'number',
minimum: 0.5,
maximum: 2,
default: 1,
},
},
},
defaultConfig: { voice: 'alloy', speed: 1 },
executor: 'ai.tts',
}
const SpeechToText: BlockDefinition = {
type: 'ai.stt',
category: 'ai',
name: 'Speech to Text',
description: 'Transcribe audio to text',
icon: '🎤',
color: '#ec4899',
inputs: [
input('audio', 'Audio', 'file', { required: true }),
],
outputs: [
output('text', 'Text', 'text'),
output('segments', 'Segments', 'array'),
],
executor: 'ai.stt',
}
// =============================================================================
// OUTPUT BLOCKS
// =============================================================================
const DisplayOutput: BlockDefinition = {
type: 'output.display',
category: 'output',
name: 'Display',
description: 'Show result on canvas',
icon: '📺',
color: '#ef4444',
inputs: [
input('value', 'Value', 'any', { required: true }),
],
outputs: [],
configSchema: {
type: 'object',
properties: {
format: {
type: 'string',
enum: ['auto', 'json', 'text', 'markdown'],
default: 'auto',
},
},
},
defaultConfig: { format: 'auto' },
executor: 'output.display',
}
const LogOutput: BlockDefinition = {
type: 'output.log',
category: 'output',
name: 'Log',
description: 'Log value to console',
icon: '📋',
color: '#ef4444',
inputs: [
input('value', 'Value', 'any', { required: true }),
input('label', 'Label', 'text'),
],
outputs: [
output('passthrough', 'Pass Through', 'any'),
],
executor: 'output.log',
}
const NotifyOutput: BlockDefinition = {
type: 'output.notify',
category: 'output',
name: 'Notification',
description: 'Show browser notification',
icon: '🔔',
color: '#ef4444',
inputs: [
input('message', 'Message', 'text', { required: true }),
input('title', 'Title', 'text'),
],
outputs: [],
configSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['info', 'success', 'warning', 'error'],
default: 'info',
},
},
},
defaultConfig: { type: 'info' },
executor: 'output.notify',
}
const CreateMarkdown: BlockDefinition = {
type: 'output.markdown',
category: 'output',
name: 'Create Markdown',
description: 'Create a markdown shape on canvas',
icon: '📝',
color: '#ef4444',
inputs: [
input('content', 'Content', 'text', { required: true }),
input('x', 'X Position', 'number'),
input('y', 'Y Position', 'number'),
],
outputs: [
output('shapeId', 'Shape ID', 'text'),
],
executor: 'output.markdown',
}
// =============================================================================
// Register All Blocks
// =============================================================================
const ALL_BLOCKS: BlockDefinition[] = [
// Triggers
ManualTrigger,
ScheduleTrigger,
WebhookTrigger,
// Actions
HttpRequest,
CreateShape,
UpdateShape,
Delay,
// Conditions
IfCondition,
SwitchCondition,
Compare,
// Transformers
JsonParse,
JsonStringify,
CodeTransformer,
Template,
GetProperty,
SetProperty,
ArrayMap,
ArrayFilter,
// AI
LLMPrompt,
ImageGen,
TextToSpeech,
SpeechToText,
// Outputs
DisplayOutput,
LogOutput,
NotifyOutput,
CreateMarkdown,
]
// Register all blocks
for (const block of ALL_BLOCKS) {
BLOCK_REGISTRY.set(block.type, block)
}
// =============================================================================
// Registry Access Functions
// =============================================================================
/**
* Get a block definition by type
*/
export function getBlockDefinition(type: string): BlockDefinition {
const def = BLOCK_REGISTRY.get(type)
if (!def) {
throw new Error(`Unknown block type: ${type}`)
}
return def
}
/**
* Check if a block type exists
*/
export function hasBlockDefinition(type: string): boolean {
return BLOCK_REGISTRY.has(type)
}
/**
* Get all registered block definitions
*/
export function getAllBlockDefinitions(): BlockDefinition[] {
return Array.from(BLOCK_REGISTRY.values())
}
/**
* Get blocks filtered by category
*/
export function getBlocksByCategory(category: BlockCategory): BlockDefinition[] {
return getAllBlockDefinitions().filter(b => b.category === category)
}
/**
* Register a new block definition
*/
export function registerBlock(definition: BlockDefinition): void {
if (BLOCK_REGISTRY.has(definition.type)) {
console.warn(`Block type "${definition.type}" is already registered. Overwriting.`)
}
BLOCK_REGISTRY.set(definition.type, definition)
}
/**
* Get all block categories with their blocks
*/
export function getBlocksByCategories(): Record<BlockCategory, BlockDefinition[]> {
const result: Record<BlockCategory, BlockDefinition[]> = {
trigger: [],
action: [],
condition: [],
transformer: [],
output: [],
ai: [],
}
for (const block of BLOCK_REGISTRY.values()) {
result[block.category].push(block)
}
return result
}

View File

@ -0,0 +1,731 @@
/**
* Workflow Executor
*
* Executes workflow blocks either individually or as a complete workflow.
* Manages execution state, handles data propagation between blocks,
* and supports both manual and real-time execution modes.
*/
import { Editor, TLShapeId } from 'tldraw'
import {
ExecutionContext,
BlockExecutionResult,
WorkflowBlockProps,
ExecutionState,
} from './types'
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
import {
getBlockInputBindings,
getBlockOutputBindings,
getExecutionOrder,
buildWorkflowGraph,
} from './portBindings'
import { validateRequiredInputs, validateWorkflow } from './validation'
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
// =============================================================================
// Block Executors
// =============================================================================
type BlockExecutor = (
context: ExecutionContext,
inputs: Record<string, unknown>,
config: Record<string, unknown>
) => Promise<Record<string, unknown>>
const blockExecutors: Map<string, BlockExecutor> = new Map()
/**
* Register a block executor
*/
export function registerBlockExecutor(
blockType: string,
executor: BlockExecutor
): void {
blockExecutors.set(blockType, executor)
}
// =============================================================================
// Built-in Block Executors
// =============================================================================
// Trigger: Manual
registerBlockExecutor('trigger.manual', async (context) => {
return { timestamp: Date.now() }
})
// Trigger: Schedule
registerBlockExecutor('trigger.schedule', async (context, inputs, config) => {
return {
timestamp: Date.now(),
scheduledTime: config.time || '00:00',
interval: config.interval || 'daily',
}
})
// Trigger: Webhook
registerBlockExecutor('trigger.webhook', async (context, inputs, config) => {
return {
timestamp: Date.now(),
method: 'POST',
body: {},
headers: {},
}
})
// Action: HTTP Request
registerBlockExecutor('action.http', async (context, inputs, config) => {
const url = (inputs.url as string) || (config.url as string)
const method = (config.method as string) || 'GET'
const body = inputs.body as string | undefined
if (!url) {
throw new Error('URL is required for HTTP request')
}
try {
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: method !== 'GET' ? body : undefined,
})
const responseData = await response.text()
let parsedData: unknown = responseData
try {
parsedData = JSON.parse(responseData)
} catch {
// Keep as text if not valid JSON
}
return {
response: parsedData,
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
}
} catch (error) {
throw new Error(`HTTP request failed: ${(error as Error).message}`)
}
})
// Action: Create Shape
registerBlockExecutor('action.createShape', async (context, inputs, config) => {
const shapeType = (config.shapeType as string) || 'text'
const position = (inputs.position as { x: number; y: number }) || { x: 100, y: 100 }
const content = inputs.content as string || ''
// Create shape through editor
const newShape = context.editor.createShape({
type: shapeType === 'text' ? 'text' : 'geo',
x: position.x,
y: position.y,
props: shapeType === 'text'
? { text: content }
: { text: content, w: 200, h: 100 },
})
return {
shapeId: newShape?.id || null,
created: true,
}
})
// Action: Update Shape
registerBlockExecutor('action.updateShape', async (context, inputs, config) => {
const shapeId = inputs.shapeId as TLShapeId
const updates = inputs.updates as Record<string, unknown>
if (!shapeId) {
throw new Error('Shape ID is required')
}
context.editor.updateShape({
id: shapeId,
type: context.editor.getShape(shapeId)?.type || 'geo',
props: updates,
})
return { updated: true, shapeId }
})
// Action: Delay
registerBlockExecutor('action.delay', async (context, inputs, config) => {
const duration = (config.duration as number) || 1000
await new Promise(resolve => setTimeout(resolve, duration))
return { passthrough: inputs.input, delayed: duration }
})
// Condition: If/Else
registerBlockExecutor('condition.if', async (context, inputs) => {
const condition = Boolean(inputs.condition)
const value = inputs.value
return condition
? { true: value }
: { false: value }
})
// Condition: Switch
registerBlockExecutor('condition.switch', async (context, inputs, config) => {
const value = inputs.value
const cases = (config.cases as Record<string, unknown>) || {}
for (const [caseValue, output] of Object.entries(cases)) {
if (String(value) === caseValue) {
return { match: output, matched: caseValue }
}
}
return { default: value }
})
// Condition: Compare
registerBlockExecutor('condition.compare', async (context, inputs, config) => {
const a = inputs.a
const b = inputs.b
const operator = (config.operator as string) || 'equals'
let result: boolean
switch (operator) {
case 'equals':
result = a === b
break
case 'notEquals':
result = a !== b
break
case 'greaterThan':
result = Number(a) > Number(b)
break
case 'lessThan':
result = Number(a) < Number(b)
break
case 'contains':
result = String(a).includes(String(b))
break
default:
result = a === b
}
return { result }
})
// Transformer: JSON Parse
registerBlockExecutor('transformer.jsonParse', async (context, inputs) => {
const input = inputs.input as string
try {
return { output: JSON.parse(input) }
} catch (error) {
throw new Error(`Invalid JSON: ${(error as Error).message}`)
}
})
// Transformer: JSON Stringify
registerBlockExecutor('transformer.jsonStringify', async (context, inputs, config) => {
const input = inputs.input
const pretty = config.pretty as boolean
return {
output: pretty
? JSON.stringify(input, null, 2)
: JSON.stringify(input),
}
})
// Transformer: JavaScript Code
registerBlockExecutor('transformer.code', async (context, inputs, config) => {
const code = config.code as string
const input = inputs.input
if (!code) {
return { output: input }
}
try {
// Create a sandboxed function
const fn = new Function('input', 'context', `
'use strict';
${code}
`)
const result = fn(input, { timestamp: Date.now() })
return { output: result }
} catch (error) {
throw new Error(`Code execution failed: ${(error as Error).message}`)
}
})
// Transformer: Template
registerBlockExecutor('transformer.template', async (context, inputs, config) => {
const template = (config.template as string) || ''
const variables = (inputs.variables as Record<string, unknown>) || {}
let result = template
for (const [key, value] of Object.entries(variables)) {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value))
}
return { output: result }
})
// Transformer: Get Property
registerBlockExecutor('transformer.getProperty', async (context, inputs, config) => {
const object = inputs.object as Record<string, unknown>
const path = (config.path as string) || ''
if (!object || typeof object !== 'object') {
return { value: undefined }
}
const parts = path.split('.')
let value: unknown = object
for (const part of parts) {
if (value && typeof value === 'object' && part in (value as Record<string, unknown>)) {
value = (value as Record<string, unknown>)[part]
} else {
return { value: undefined }
}
}
return { value }
})
// Transformer: Set Property
registerBlockExecutor('transformer.setProperty', async (context, inputs, config) => {
const object = { ...(inputs.object as Record<string, unknown>) } || {}
const path = (config.path as string) || ''
const value = inputs.value
if (path) {
const parts = path.split('.')
let current = object as Record<string, unknown>
for (let i = 0; i < parts.length - 1; i++) {
if (!(parts[i] in current)) {
current[parts[i]] = {}
}
current = current[parts[i]] as Record<string, unknown>
}
current[parts[parts.length - 1]] = value
}
return { output: object }
})
// Transformer: Array Map
registerBlockExecutor('transformer.arrayMap', async (context, inputs, config) => {
const array = inputs.array as unknown[]
const expression = (config.expression as string) || 'item'
if (!Array.isArray(array)) {
throw new Error('Input must be an array')
}
const fn = new Function('item', 'index', `return ${expression}`)
return { output: array.map((item, index) => fn(item, index)) }
})
// Transformer: Array Filter
registerBlockExecutor('transformer.arrayFilter', async (context, inputs, config) => {
const array = inputs.array as unknown[]
const condition = (config.condition as string) || 'true'
if (!Array.isArray(array)) {
throw new Error('Input must be an array')
}
const fn = new Function('item', 'index', `return ${condition}`)
return { output: array.filter((item, index) => fn(item, index)) }
})
// AI: LLM Prompt (placeholder - integrates with existing AI utilities)
registerBlockExecutor('ai.llm', async (context, inputs, config) => {
const prompt = inputs.prompt as string
const systemPrompt = (config.systemPrompt as string) || ''
// TODO: Integrate with existing LLM utilities
// For now, return a placeholder
console.log('LLM Prompt:', { prompt, systemPrompt })
return {
response: `[LLM response to: "${prompt?.substring(0, 50)}..."]`,
tokens: 0,
}
})
// AI: Image Generation (placeholder)
registerBlockExecutor('ai.imageGen', async (context, inputs, config) => {
const prompt = inputs.prompt as string
const size = (config.size as string) || '512x512'
// TODO: Integrate with existing image generation
console.log('Image Gen:', { prompt, size })
return {
image: '[Image URL placeholder]',
prompt,
}
})
// AI: Text to Speech (placeholder)
registerBlockExecutor('ai.tts', async (context, inputs, config) => {
const text = inputs.text as string
const voice = (config.voice as string) || 'default'
console.log('TTS:', { text, voice })
return {
audio: '[Audio URL placeholder]',
duration: 0,
}
})
// AI: Speech to Text (placeholder)
registerBlockExecutor('ai.stt', async (context, inputs) => {
const audio = inputs.audio as string
console.log('STT:', { audio })
return {
text: '[Transcription placeholder]',
confidence: 0,
}
})
// Output: Display
registerBlockExecutor('output.display', async (context, inputs, config) => {
const value = inputs.value
const format = (config.format as string) || 'auto'
let displayed: string
if (format === 'json') {
displayed = JSON.stringify(value, null, 2)
} else if (format === 'text') {
displayed = String(value)
} else {
displayed = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)
}
console.log('Display:', displayed)
return { displayed }
})
// Output: Log
registerBlockExecutor('output.log', async (context, inputs, config) => {
const message = inputs.message
const level = (config.level as string) || 'info'
const timestamp = new Date().toISOString()
console.log(`[${level.toUpperCase()}] ${timestamp}:`, message)
return { logged: true, timestamp, level }
})
// Output: Notify
registerBlockExecutor('output.notify', async (context, inputs, config) => {
const message = inputs.message as string
const title = (config.title as string) || 'Notification'
// Dispatch notification event
window.dispatchEvent(new CustomEvent('workflow:notify', {
detail: { title, message },
}))
return { notified: true }
})
// Output: Create Markdown
registerBlockExecutor('output.markdown', async (context, inputs, config) => {
const content = inputs.content as string
const position = (inputs.position as { x: number; y: number }) || { x: 100, y: 100 }
// Create a markdown shape
const newShape = context.editor.createShape({
type: 'Markdown',
x: position.x,
y: position.y,
props: {
w: 400,
h: 300,
text: content,
},
})
return { shapeId: newShape?.id || null, created: true }
})
// =============================================================================
// Execution Functions
// =============================================================================
/**
* Execute a single workflow block
*/
export async function executeBlock(
editor: Editor,
blockId: TLShapeId,
additionalInputs: Record<string, unknown> = {}
): Promise<BlockExecutionResult> {
const shape = editor.getShape(blockId) as IWorkflowBlock | undefined
if (!shape || shape.type !== 'WorkflowBlock') {
return {
success: false,
error: 'Invalid block shape',
outputs: {},
executionTime: 0,
}
}
const { blockType, blockConfig, inputValues } = shape.props
if (!hasBlockDefinition(blockType)) {
return {
success: false,
error: `Unknown block type: ${blockType}`,
outputs: {},
executionTime: 0,
}
}
// Get executor
const executor = blockExecutors.get(blockType)
if (!executor) {
return {
success: false,
error: `No executor registered for block type: ${blockType}`,
outputs: {},
executionTime: 0,
}
}
// Update execution state to running
updateBlockState(editor, blockId, 'running')
const startTime = Date.now()
try {
// Gather inputs from upstream blocks
const inputs = await gatherBlockInputs(editor, blockId)
// Merge with additional inputs and stored input values
const mergedInputs = {
...inputValues,
...inputs,
...additionalInputs,
}
// Create execution context
const context: ExecutionContext = {
editor,
blockId,
timestamp: Date.now(),
}
// Execute
const outputs = await executor(context, mergedInputs, blockConfig)
// Update block with outputs
editor.updateShape<IWorkflowBlock>({
id: blockId,
type: 'WorkflowBlock',
props: {
outputValues: outputs,
executionState: 'success',
executionError: undefined,
},
})
return {
success: true,
outputs,
executionTime: Date.now() - startTime,
}
} catch (error) {
const errorMessage = (error as Error).message
// Update block with error
editor.updateShape<IWorkflowBlock>({
id: blockId,
type: 'WorkflowBlock',
props: {
executionState: 'error',
executionError: errorMessage,
},
})
return {
success: false,
error: errorMessage,
outputs: {},
executionTime: Date.now() - startTime,
}
}
}
/**
* Gather input values from upstream connected blocks
*/
async function gatherBlockInputs(
editor: Editor,
blockId: TLShapeId
): Promise<Record<string, unknown>> {
const inputs: Record<string, unknown> = {}
const bindings = getBlockInputBindings(editor, blockId)
for (const binding of bindings) {
const sourceShape = editor.getShape(binding.fromShapeId) as IWorkflowBlock | undefined
if (sourceShape && sourceShape.type === 'WorkflowBlock') {
const outputValue = sourceShape.props.outputValues?.[binding.fromPortId]
if (outputValue !== undefined) {
inputs[binding.toPortId] = outputValue
}
}
}
return inputs
}
/**
* Update block execution state
*/
function updateBlockState(
editor: Editor,
blockId: TLShapeId,
state: ExecutionState,
error?: string
): void {
editor.updateShape<IWorkflowBlock>({
id: blockId,
type: 'WorkflowBlock',
props: {
executionState: state,
executionError: error,
},
})
}
/**
* Execute an entire workflow starting from trigger blocks
*/
export async function executeWorkflow(
editor: Editor,
options: {
startBlockId?: TLShapeId
signal?: AbortSignal
onProgress?: (completed: number, total: number) => void
} = {}
): Promise<{
success: boolean
results: Map<TLShapeId, BlockExecutionResult>
error?: string
}> {
const { signal, onProgress } = options
// Validate workflow first
const { blocks, connections } = buildWorkflowGraph(editor)
const validation = validateWorkflow(blocks, connections)
if (!validation.valid) {
return {
success: false,
results: new Map(),
error: validation.errors.map(e => e.message).join('; '),
}
}
// Get execution order
const executionOrder = getExecutionOrder(editor)
// If start block specified, only execute that subgraph
let blocksToExecute = executionOrder
if (options.startBlockId) {
const startIndex = executionOrder.indexOf(options.startBlockId)
if (startIndex >= 0) {
blocksToExecute = executionOrder.slice(startIndex)
}
}
const results = new Map<TLShapeId, BlockExecutionResult>()
let completed = 0
// Reset all blocks to idle
for (const blockId of blocksToExecute) {
updateBlockState(editor, blockId, 'idle')
}
// Execute blocks in order
for (const blockId of blocksToExecute) {
// Check for abort
if (signal?.aborted) {
return {
success: false,
results,
error: 'Execution aborted',
}
}
const result = await executeBlock(editor, blockId)
results.set(blockId, result)
if (!result.success) {
// Stop on first error
return {
success: false,
results,
error: `Block execution failed: ${result.error}`,
}
}
completed++
onProgress?.(completed, blocksToExecute.length)
}
return {
success: true,
results,
}
}
/**
* Reset all workflow blocks to idle state
*/
export function resetWorkflow(editor: Editor): void {
const blocks = editor.getCurrentPageShapes().filter(s => s.type === 'WorkflowBlock')
for (const block of blocks) {
editor.updateShape<IWorkflowBlock>({
id: block.id,
type: 'WorkflowBlock',
props: {
executionState: 'idle',
executionError: undefined,
outputValues: {},
},
})
}
}
// =============================================================================
// Event Listener for Manual Block Execution
// =============================================================================
/**
* Setup event listener for workflow:execute-block events
*/
export function setupBlockExecutionListener(editor: Editor): () => void {
const handler = async (event: CustomEvent<{ blockId: TLShapeId }>) => {
const { blockId } = event.detail
await executeBlock(editor, blockId)
}
window.addEventListener('workflow:execute-block', handler as EventListener)
return () => {
window.removeEventListener('workflow:execute-block', handler as EventListener)
}
}

View File

@ -0,0 +1,468 @@
/**
* Port Binding Utilities
*
* Handles the connection between workflow blocks via arrows.
* Stores port metadata in arrow meta and provides utilities for
* querying connections between blocks.
*/
import {
Editor,
TLArrowBinding,
TLArrowShape,
TLShape,
TLShapeId,
Vec,
} from 'tldraw'
import { PortBinding } from './types'
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
import { validateConnection } from './validation'
// =============================================================================
// Port Position Constants
// =============================================================================
const PORT_SIZE = 12
const PORT_SPACING = 28
const HEADER_HEIGHT = 36
// =============================================================================
// Port Position Calculation
// =============================================================================
/**
* Get the position of a port in world coordinates
*/
export function getPortWorldPosition(
editor: Editor,
shapeId: TLShapeId,
portId: string,
direction: 'input' | 'output'
): Vec | null {
const shape = editor.getShape(shapeId)
if (!shape || shape.type !== 'WorkflowBlock') return null
const props = shape.props as { w: number; blockType: string }
if (!hasBlockDefinition(props.blockType)) return null
const definition = getBlockDefinition(props.blockType)
const ports = direction === 'input' ? definition.inputs : definition.outputs
const portIndex = ports.findIndex(p => p.id === portId)
if (portIndex === -1) return null
// Calculate local position
const localX = direction === 'input' ? 0 : props.w
const localY = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2
// Transform to world coordinates
const point = editor.getShapePageTransform(shapeId)?.applyToPoint({ x: localX, y: localY })
return point ? new Vec(point.x, point.y) : null
}
/**
* Find the closest port to a given point on a workflow block
*/
export function findClosestPort(
editor: Editor,
shapeId: TLShapeId,
point: Vec,
direction: 'input' | 'output'
): { portId: string; distance: number } | null {
const shape = editor.getShape(shapeId)
if (!shape || shape.type !== 'WorkflowBlock') return null
const props = shape.props as { blockType: string }
if (!hasBlockDefinition(props.blockType)) return null
const definition = getBlockDefinition(props.blockType)
const ports = direction === 'input' ? definition.inputs : definition.outputs
let closestPort: { portId: string; distance: number } | null = null
for (const port of ports) {
const portPos = getPortWorldPosition(editor, shapeId, port.id, direction)
if (!portPos) continue
const distance = Vec.Dist(point, portPos)
if (!closestPort || distance < closestPort.distance) {
closestPort = { portId: port.id, distance }
}
}
return closestPort
}
// =============================================================================
// Arrow Port Metadata
// =============================================================================
/**
* Arrow meta type for workflow connections
*/
interface WorkflowArrowMeta {
fromPortId?: string
toPortId?: string
validated?: boolean
}
/**
* Get port binding from an arrow shape
*/
export function getPortBinding(
editor: Editor,
arrowId: TLShapeId
): PortBinding | null {
const arrow = editor.getShape(arrowId) as TLArrowShape | undefined
if (!arrow || arrow.type !== 'arrow') return null
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(arrowId)
if (!bindings || bindings.length !== 2) return null
// Find start and end bindings
const startBinding = bindings.find(b => b.props.terminal === 'start')
const endBinding = bindings.find(b => b.props.terminal === 'end')
if (!startBinding || !endBinding) return null
// Get meta from arrow
const meta = (arrow.meta || {}) as WorkflowArrowMeta
return {
fromShapeId: startBinding.toId,
fromPortId: meta.fromPortId || 'output',
toShapeId: endBinding.toId,
toPortId: meta.toPortId || 'input',
arrowId,
}
}
/**
* Set port binding metadata on an arrow
*/
export function setPortBinding(
editor: Editor,
arrowId: TLShapeId,
fromPortId: string,
toPortId: string
): void {
const arrow = editor.getShape(arrowId) as TLArrowShape | undefined
if (!arrow || arrow.type !== 'arrow') return
editor.updateShape({
id: arrowId,
type: 'arrow',
meta: {
...arrow.meta,
fromPortId,
toPortId,
validated: true,
},
})
}
/**
* Clear port binding metadata from an arrow
*/
export function clearPortBinding(
editor: Editor,
arrowId: TLShapeId
): void {
const arrow = editor.getShape(arrowId) as TLArrowShape | undefined
if (!arrow || arrow.type !== 'arrow') return
const meta = { ...arrow.meta } as WorkflowArrowMeta
delete meta.fromPortId
delete meta.toPortId
delete meta.validated
editor.updateShape({
id: arrowId,
type: 'arrow',
meta,
})
}
// =============================================================================
// Connection Queries
// =============================================================================
/**
* Get all input bindings for a workflow block
*/
export function getBlockInputBindings(
editor: Editor,
shapeId: TLShapeId
): PortBinding[] {
const bindings: PortBinding[] = []
// Get all arrows ending at this shape
const arrowBindings = editor.getBindingsToShape<TLArrowBinding>(shapeId, 'arrow')
const incomingArrows = arrowBindings
.filter(b => b.props.terminal === 'end')
.map(b => b.fromId)
for (const arrowId of incomingArrows) {
const portBinding = getPortBinding(editor, arrowId)
if (portBinding) {
bindings.push(portBinding)
}
}
return bindings
}
/**
* Get all output bindings from a workflow block
*/
export function getBlockOutputBindings(
editor: Editor,
shapeId: TLShapeId
): PortBinding[] {
const bindings: PortBinding[] = []
// Get all arrows starting from this shape
const arrowBindings = editor.getBindingsToShape<TLArrowBinding>(shapeId, 'arrow')
const outgoingArrows = arrowBindings
.filter(b => b.props.terminal === 'start')
.map(b => b.fromId)
for (const arrowId of outgoingArrows) {
const portBinding = getPortBinding(editor, arrowId)
if (portBinding) {
bindings.push(portBinding)
}
}
return bindings
}
/**
* Get bindings for a specific input port
*/
export function getInputPortBindings(
editor: Editor,
shapeId: TLShapeId,
portId: string
): PortBinding[] {
return getBlockInputBindings(editor, shapeId).filter(b => b.toPortId === portId)
}
/**
* Get bindings for a specific output port
*/
export function getOutputPortBindings(
editor: Editor,
shapeId: TLShapeId,
portId: string
): PortBinding[] {
return getBlockOutputBindings(editor, shapeId).filter(b => b.fromPortId === portId)
}
/**
* Check if a specific port is connected
*/
export function isPortConnected(
editor: Editor,
shapeId: TLShapeId,
portId: string,
direction: 'input' | 'output'
): boolean {
const bindings = direction === 'input'
? getBlockInputBindings(editor, shapeId)
: getBlockOutputBindings(editor, shapeId)
const portKey = direction === 'input' ? 'toPortId' : 'fromPortId'
return bindings.some(b => b[portKey] === portId)
}
/**
* Get all connected ports for a shape
*/
export function getConnectedPorts(
editor: Editor,
shapeId: TLShapeId
): { inputs: string[]; outputs: string[] } {
const inputBindings = getBlockInputBindings(editor, shapeId)
const outputBindings = getBlockOutputBindings(editor, shapeId)
return {
inputs: [...new Set(inputBindings.map(b => b.toPortId))],
outputs: [...new Set(outputBindings.map(b => b.fromPortId))],
}
}
// =============================================================================
// Workflow Graph Utilities
// =============================================================================
/**
* Get upstream blocks (blocks that feed into this one)
*/
export function getUpstreamBlocks(
editor: Editor,
shapeId: TLShapeId
): TLShapeId[] {
const inputBindings = getBlockInputBindings(editor, shapeId)
return [...new Set(inputBindings.map(b => b.fromShapeId))]
}
/**
* Get downstream blocks (blocks that this one feeds into)
*/
export function getDownstreamBlocks(
editor: Editor,
shapeId: TLShapeId
): TLShapeId[] {
const outputBindings = getBlockOutputBindings(editor, shapeId)
return [...new Set(outputBindings.map(b => b.toShapeId))]
}
/**
* Get all workflow blocks in the editor
*/
export function getAllWorkflowBlocks(editor: Editor): TLShape[] {
return editor.getCurrentPageShapes().filter(s => s.type === 'WorkflowBlock')
}
/**
* Get all arrows connecting workflow blocks
*/
export function getWorkflowArrows(editor: Editor): TLArrowShape[] {
const workflowBlockIds = new Set(
getAllWorkflowBlocks(editor).map(s => s.id)
)
return (editor.getCurrentPageShapes().filter(s => s.type === 'arrow') as TLArrowShape[])
.filter(arrow => {
const binding = getPortBinding(editor, arrow.id)
return binding &&
workflowBlockIds.has(binding.fromShapeId) &&
workflowBlockIds.has(binding.toShapeId)
})
}
/**
* Build a workflow graph from the current canvas
*/
export function buildWorkflowGraph(editor: Editor): {
blocks: Array<{ id: TLShapeId; blockType: string; config: Record<string, unknown> }>
connections: PortBinding[]
} {
const blocks = getAllWorkflowBlocks(editor).map(shape => {
const props = shape.props as { blockType: string; blockConfig: Record<string, unknown> }
return {
id: shape.id,
blockType: props.blockType,
config: props.blockConfig || {},
}
})
const connections: PortBinding[] = []
for (const arrow of getWorkflowArrows(editor)) {
const binding = getPortBinding(editor, arrow.id)
if (binding) {
connections.push(binding)
}
}
return { blocks, connections }
}
/**
* Get topologically sorted execution order
*/
export function getExecutionOrder(editor: Editor): TLShapeId[] {
const { blocks, connections } = buildWorkflowGraph(editor)
// Build adjacency list
const inDegree = new Map<TLShapeId, number>()
const outEdges = new Map<TLShapeId, TLShapeId[]>()
for (const block of blocks) {
inDegree.set(block.id, 0)
outEdges.set(block.id, [])
}
for (const conn of connections) {
inDegree.set(conn.toShapeId, (inDegree.get(conn.toShapeId) || 0) + 1)
outEdges.get(conn.fromShapeId)?.push(conn.toShapeId)
}
// Kahn's algorithm
const queue: TLShapeId[] = []
const result: TLShapeId[] = []
for (const [id, degree] of inDegree) {
if (degree === 0) queue.push(id)
}
while (queue.length > 0) {
const node = queue.shift()!
result.push(node)
for (const neighbor of outEdges.get(node) || []) {
const newDegree = (inDegree.get(neighbor) || 1) - 1
inDegree.set(neighbor, newDegree)
if (newDegree === 0) queue.push(neighbor)
}
}
return result
}
// =============================================================================
// Connection Validation Helpers
// =============================================================================
/**
* Validate if a potential connection is valid
*/
export function canCreateConnection(
editor: Editor,
fromShapeId: TLShapeId,
fromPortId: string,
toShapeId: TLShapeId,
toPortId: string
): { valid: boolean; error?: string } {
const fromShape = editor.getShape(fromShapeId)
const toShape = editor.getShape(toShapeId)
if (!fromShape || fromShape.type !== 'WorkflowBlock') {
return { valid: false, error: 'Source is not a workflow block' }
}
if (!toShape || toShape.type !== 'WorkflowBlock') {
return { valid: false, error: 'Target is not a workflow block' }
}
const fromProps = fromShape.props as { blockType: string }
const toProps = toShape.props as { blockType: string }
const result = validateConnection(
fromProps.blockType,
fromPortId,
toProps.blockType,
toPortId
)
if (!result.valid) {
return { valid: false, error: result.errors[0]?.message }
}
return { valid: true }
}
/**
* Get block type from a shape ID
*/
export function getBlockType(
editor: Editor,
shapeId: TLShapeId
): string | undefined {
const shape = editor.getShape(shapeId)
if (!shape || shape.type !== 'WorkflowBlock') return undefined
const props = shape.props as { blockType: string }
return props.blockType
}

View File

@ -0,0 +1,659 @@
/**
* Workflow Serialization
*
* Export and import workflows as JSON for sharing, backup,
* and loading templates. Compatible with Flowy JSON format.
*/
import { Editor, TLShapeId, createShapeId } from 'tldraw'
import { PortBinding, WorkflowBlockProps } from './types'
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
import {
buildWorkflowGraph,
getPortBinding,
setPortBinding,
getAllWorkflowBlocks,
} from './portBindings'
import { hasBlockDefinition, getBlockDefinition } from './blockRegistry'
// =============================================================================
// Serialized Types
// =============================================================================
/**
* Serialized block format
*/
interface SerializedBlock {
id: string
type: string
x: number
y: number
w: number
h: number
blockType: string
blockConfig: Record<string, unknown>
inputValues?: Record<string, unknown>
tags?: string[]
}
/**
* Serialized connection format
*/
interface SerializedConnection {
id: string
from: {
blockId: string
portId: string
}
to: {
blockId: string
portId: string
}
}
/**
* Full workflow export format
*/
export interface SerializedWorkflow {
version: string
name: string
description?: string
createdAt: string
blocks: SerializedBlock[]
connections: SerializedConnection[]
metadata?: Record<string, unknown>
}
// =============================================================================
// Export Functions
// =============================================================================
/**
* Export a workflow to JSON format
*/
export function exportWorkflow(
editor: Editor,
options: {
name?: string
description?: string
includeInputValues?: boolean
blockIds?: TLShapeId[]
} = {}
): SerializedWorkflow {
const {
name = 'Untitled Workflow',
description,
includeInputValues = false,
blockIds,
} = options
// Get blocks to export
let blocks = getAllWorkflowBlocks(editor) as IWorkflowBlock[]
if (blockIds && blockIds.length > 0) {
const blockIdSet = new Set(blockIds)
blocks = blocks.filter(b => blockIdSet.has(b.id))
}
// Serialize blocks
const serializedBlocks: SerializedBlock[] = blocks.map(block => ({
id: block.id,
type: block.type,
x: block.x,
y: block.y,
w: block.props.w,
h: block.props.h,
blockType: block.props.blockType,
blockConfig: block.props.blockConfig,
inputValues: includeInputValues ? block.props.inputValues : undefined,
tags: block.props.tags,
}))
// Get connections between exported blocks
const blockIdSet = new Set(blocks.map(b => b.id))
const connections: SerializedConnection[] = []
// Find all arrows connecting our blocks
const arrows = editor.getCurrentPageShapes().filter(s => s.type === 'arrow')
for (const arrow of arrows) {
const binding = getPortBinding(editor, arrow.id)
if (
binding &&
blockIdSet.has(binding.fromShapeId) &&
blockIdSet.has(binding.toShapeId)
) {
connections.push({
id: arrow.id,
from: {
blockId: binding.fromShapeId,
portId: binding.fromPortId,
},
to: {
blockId: binding.toShapeId,
portId: binding.toPortId,
},
})
}
}
return {
version: '1.0.0',
name,
description,
createdAt: new Date().toISOString(),
blocks: serializedBlocks,
connections,
}
}
/**
* Export workflow to JSON string
*/
export function exportWorkflowToJSON(
editor: Editor,
options?: Parameters<typeof exportWorkflow>[1]
): string {
return JSON.stringify(exportWorkflow(editor, options), null, 2)
}
/**
* Download workflow as JSON file
*/
export function downloadWorkflow(
editor: Editor,
options?: Parameters<typeof exportWorkflow>[1]
): void {
const workflow = exportWorkflow(editor, options)
const json = JSON.stringify(workflow, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const filename = `${workflow.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json`
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// =============================================================================
// Import Functions
// =============================================================================
/**
* Import a workflow from JSON format
*/
export function importWorkflow(
editor: Editor,
workflow: SerializedWorkflow,
options: {
offset?: { x: number; y: number }
preserveIds?: boolean
} = {}
): {
success: boolean
blockIds: TLShapeId[]
errors: string[]
} {
const { offset = { x: 0, y: 0 }, preserveIds = false } = options
const errors: string[] = []
const blockIdMap = new Map<string, TLShapeId>()
const newBlockIds: TLShapeId[] = []
// Calculate bounds for centering
let minX = Infinity
let minY = Infinity
for (const block of workflow.blocks) {
minX = Math.min(minX, block.x)
minY = Math.min(minY, block.y)
}
// Create blocks
for (const block of workflow.blocks) {
// Validate block type
if (!hasBlockDefinition(block.blockType)) {
errors.push(`Unknown block type: ${block.blockType}`)
continue
}
const definition = getBlockDefinition(block.blockType)
// Generate or preserve ID
const newId = preserveIds && block.id.startsWith('shape:')
? block.id as TLShapeId
: createShapeId()
blockIdMap.set(block.id, newId)
// Calculate position with offset
const x = block.x - minX + offset.x
const y = block.y - minY + offset.y
// Calculate height based on ports
const maxPorts = Math.max(definition.inputs.length, definition.outputs.length)
const height = Math.max(block.h, 36 + 24 + maxPorts * 28 + 60)
// Create the block
try {
editor.createShape<IWorkflowBlock>({
id: newId,
type: 'WorkflowBlock',
x,
y,
props: {
w: block.w,
h: height,
blockType: block.blockType,
blockConfig: block.blockConfig || {},
inputValues: block.inputValues || {},
outputValues: {},
executionState: 'idle',
tags: block.tags || ['workflow'],
pinnedToView: false,
},
})
newBlockIds.push(newId)
} catch (error) {
errors.push(`Failed to create block: ${(error as Error).message}`)
}
}
// Create connections (arrows)
for (const conn of workflow.connections) {
const fromId = blockIdMap.get(conn.from.blockId)
const toId = blockIdMap.get(conn.to.blockId)
if (!fromId || !toId) {
errors.push(`Connection references missing block`)
continue
}
const fromBlock = editor.getShape(fromId) as IWorkflowBlock | undefined
const toBlock = editor.getShape(toId) as IWorkflowBlock | undefined
if (!fromBlock || !toBlock) continue
try {
// Create arrow between blocks
const arrowId = createShapeId()
// Get port positions for arrow endpoints
const fromDef = getBlockDefinition(fromBlock.props.blockType)
const toDef = getBlockDefinition(toBlock.props.blockType)
const fromPortIndex = fromDef.outputs.findIndex(p => p.id === conn.from.portId)
const toPortIndex = toDef.inputs.findIndex(p => p.id === conn.to.portId)
if (fromPortIndex === -1 || toPortIndex === -1) {
errors.push(`Invalid port in connection`)
continue
}
// Calculate port positions
const fromX = fromBlock.x + fromBlock.props.w
const fromY = fromBlock.y + 36 + 12 + fromPortIndex * 28 + 6
const toX = toBlock.x
const toY = toBlock.y + 36 + 12 + toPortIndex * 28 + 6
// Create arrow with bindings
editor.createShape({
id: arrowId,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: fromX, y: fromY },
end: { x: toX, y: toY },
color: 'black',
},
meta: {
fromPortId: conn.from.portId,
toPortId: conn.to.portId,
validated: true,
},
})
// Create bindings for the arrow
editor.createBinding({
type: 'arrow',
fromId: arrowId,
toId: fromId,
props: {
terminal: 'start',
normalizedAnchor: { x: 1, y: 0.5 },
isPrecise: false,
isExact: false,
},
})
editor.createBinding({
type: 'arrow',
fromId: arrowId,
toId: toId,
props: {
terminal: 'end',
normalizedAnchor: { x: 0, y: 0.5 },
isPrecise: false,
isExact: false,
},
})
} catch (error) {
errors.push(`Failed to create connection: ${(error as Error).message}`)
}
}
return {
success: errors.length === 0,
blockIds: newBlockIds,
errors,
}
}
/**
* Import workflow from JSON string
*/
export function importWorkflowFromJSON(
editor: Editor,
json: string,
options?: Parameters<typeof importWorkflow>[2]
): ReturnType<typeof importWorkflow> {
try {
const workflow = JSON.parse(json) as SerializedWorkflow
return importWorkflow(editor, workflow, options)
} catch (error) {
return {
success: false,
blockIds: [],
errors: [`Invalid JSON: ${(error as Error).message}`],
}
}
}
/**
* Load workflow from file
*/
export async function loadWorkflowFromFile(
editor: Editor,
file: File,
options?: Parameters<typeof importWorkflow>[2]
): Promise<ReturnType<typeof importWorkflow>> {
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = (e) => {
const json = e.target?.result as string
resolve(importWorkflowFromJSON(editor, json, options))
}
reader.onerror = () => {
resolve({
success: false,
blockIds: [],
errors: ['Failed to read file'],
})
}
reader.readAsText(file)
})
}
// =============================================================================
// Workflow Templates
// =============================================================================
/**
* Pre-built workflow templates
*/
export const WORKFLOW_TEMPLATES: Record<string, SerializedWorkflow> = {
'api-transform-display': {
version: '1.0.0',
name: 'API Transform Display',
description: 'Fetch data from an API, transform it, and display the result',
createdAt: new Date().toISOString(),
blocks: [
{
id: 'block-1',
type: 'WorkflowBlock',
x: 100,
y: 100,
w: 220,
h: 180,
blockType: 'trigger.manual',
blockConfig: {},
tags: ['workflow', 'trigger'],
},
{
id: 'block-2',
type: 'WorkflowBlock',
x: 400,
y: 100,
w: 220,
h: 200,
blockType: 'action.http',
blockConfig: { url: 'https://api.example.com/data', method: 'GET' },
tags: ['workflow', 'action'],
},
{
id: 'block-3',
type: 'WorkflowBlock',
x: 700,
y: 100,
w: 220,
h: 180,
blockType: 'transformer.jsonParse',
blockConfig: {},
tags: ['workflow', 'transformer'],
},
{
id: 'block-4',
type: 'WorkflowBlock',
x: 1000,
y: 100,
w: 220,
h: 180,
blockType: 'output.display',
blockConfig: { format: 'json' },
tags: ['workflow', 'output'],
},
],
connections: [
{
id: 'conn-1',
from: { blockId: 'block-1', portId: 'timestamp' },
to: { blockId: 'block-2', portId: 'trigger' },
},
{
id: 'conn-2',
from: { blockId: 'block-2', portId: 'response' },
to: { blockId: 'block-3', portId: 'input' },
},
{
id: 'conn-3',
from: { blockId: 'block-3', portId: 'output' },
to: { blockId: 'block-4', portId: 'value' },
},
],
},
'llm-chain': {
version: '1.0.0',
name: 'LLM Chain',
description: 'Chain multiple LLM prompts together',
createdAt: new Date().toISOString(),
blocks: [
{
id: 'block-1',
type: 'WorkflowBlock',
x: 100,
y: 100,
w: 220,
h: 180,
blockType: 'trigger.manual',
blockConfig: {},
tags: ['workflow', 'trigger'],
},
{
id: 'block-2',
type: 'WorkflowBlock',
x: 400,
y: 100,
w: 220,
h: 200,
blockType: 'ai.llm',
blockConfig: { systemPrompt: 'You are a helpful assistant.' },
inputValues: { prompt: 'Summarize the following topic:' },
tags: ['workflow', 'ai'],
},
{
id: 'block-3',
type: 'WorkflowBlock',
x: 700,
y: 100,
w: 220,
h: 200,
blockType: 'ai.llm',
blockConfig: { systemPrompt: 'You are a creative writer.' },
inputValues: { prompt: 'Expand on this summary:' },
tags: ['workflow', 'ai'],
},
{
id: 'block-4',
type: 'WorkflowBlock',
x: 1000,
y: 100,
w: 220,
h: 180,
blockType: 'output.display',
blockConfig: { format: 'text' },
tags: ['workflow', 'output'],
},
],
connections: [
{
id: 'conn-1',
from: { blockId: 'block-1', portId: 'timestamp' },
to: { blockId: 'block-2', portId: 'trigger' },
},
{
id: 'conn-2',
from: { blockId: 'block-2', portId: 'response' },
to: { blockId: 'block-3', portId: 'context' },
},
{
id: 'conn-3',
from: { blockId: 'block-3', portId: 'response' },
to: { blockId: 'block-4', portId: 'value' },
},
],
},
'conditional-branch': {
version: '1.0.0',
name: 'Conditional Branch',
description: 'Branch workflow based on a condition',
createdAt: new Date().toISOString(),
blocks: [
{
id: 'block-1',
type: 'WorkflowBlock',
x: 100,
y: 200,
w: 220,
h: 180,
blockType: 'trigger.manual',
blockConfig: {},
tags: ['workflow', 'trigger'],
},
{
id: 'block-2',
type: 'WorkflowBlock',
x: 400,
y: 200,
w: 220,
h: 200,
blockType: 'condition.if',
blockConfig: {},
tags: ['workflow', 'condition'],
},
{
id: 'block-3',
type: 'WorkflowBlock',
x: 700,
y: 50,
w: 220,
h: 180,
blockType: 'output.log',
blockConfig: { level: 'info' },
inputValues: { message: 'Condition was TRUE' },
tags: ['workflow', 'output'],
},
{
id: 'block-4',
type: 'WorkflowBlock',
x: 700,
y: 300,
w: 220,
h: 180,
blockType: 'output.log',
blockConfig: { level: 'warn' },
inputValues: { message: 'Condition was FALSE' },
tags: ['workflow', 'output'],
},
],
connections: [
{
id: 'conn-1',
from: { blockId: 'block-1', portId: 'timestamp' },
to: { blockId: 'block-2', portId: 'value' },
},
{
id: 'conn-2',
from: { blockId: 'block-2', portId: 'true' },
to: { blockId: 'block-3', portId: 'message' },
},
{
id: 'conn-3',
from: { blockId: 'block-2', portId: 'false' },
to: { blockId: 'block-4', portId: 'message' },
},
],
},
}
/**
* Load a workflow template
*/
export function loadTemplate(
editor: Editor,
templateId: string,
options?: Parameters<typeof importWorkflow>[2]
): ReturnType<typeof importWorkflow> {
const template = WORKFLOW_TEMPLATES[templateId]
if (!template) {
return {
success: false,
blockIds: [],
errors: [`Unknown template: ${templateId}`],
}
}
return importWorkflow(editor, template, options)
}
/**
* Get available template names
*/
export function getTemplateNames(): Array<{ id: string; name: string; description?: string }> {
return Object.entries(WORKFLOW_TEMPLATES).map(([id, template]) => ({
id,
name: template.name,
description: template.description,
}))
}

385
src/lib/workflow/types.ts Normal file
View File

@ -0,0 +1,385 @@
/**
* Workflow Builder Type Definitions
*
* Core types for the Flowy-like workflow system including:
* - Port type system for typed connections
* - Block definitions for workflow nodes
* - Execution context and results
* - Serialization format
*/
import { TLBaseShape, TLShapeId } from 'tldraw'
// =============================================================================
// Port Type System
// =============================================================================
/**
* Data types that can flow through ports
*/
export type PortDataType =
| 'text' // String data
| 'number' // Numeric data
| 'boolean' // True/false
| 'object' // JSON objects
| 'array' // Arrays of any type
| 'any' // Accepts all types
| 'file' // Binary/file data
| 'image' // Image data (base64 or URL)
/**
* Base port definition shared by inputs and outputs
*/
export interface PortDefinition {
id: string
name: string
type: PortDataType
required: boolean
description?: string
defaultValue?: unknown
}
/**
* Input port - receives data from connected output ports
*/
export interface InputPort extends PortDefinition {
direction: 'input'
accepts: PortDataType[] // Types this port can receive
}
/**
* Output port - sends data to connected input ports
*/
export interface OutputPort extends PortDefinition {
direction: 'output'
produces: PortDataType // Type this port outputs
}
export type Port = InputPort | OutputPort
// =============================================================================
// Block Categories and Definitions
// =============================================================================
/**
* Categories for organizing blocks in the palette
*/
export type BlockCategory =
| 'trigger' // Manual, schedule, webhook, event
| 'action' // API calls, canvas operations
| 'condition' // If/else, switch
| 'transformer' // Data manipulation
| 'output' // Display, export, notify
| 'ai' // LLM, image gen, etc.
/**
* Category display information
*/
export interface CategoryInfo {
name: string
icon: string
color: string
description: string
}
/**
* Complete block definition for the registry
*/
export interface BlockDefinition {
type: string // Unique identifier (e.g., 'action.http')
category: BlockCategory
name: string // Display name
description: string
icon: string // Emoji or icon identifier
color: string // Primary color for the block
inputs: InputPort[]
outputs: OutputPort[]
configSchema?: object // JSON Schema for block configuration
defaultConfig?: object // Default configuration values
executor?: string // Name of executor function
}
// =============================================================================
// Shape Types
// =============================================================================
/**
* Props stored on WorkflowBlock shapes
*/
export interface WorkflowBlockProps {
w: number
h: number
blockType: string // Reference to BlockDefinition.type
blockConfig: Record<string, unknown> // User-configured values
inputValues: Record<string, unknown> // Current input port values
outputValues: Record<string, unknown> // Current output port values
executionState: ExecutionState
executionError?: string
lastExecutedAt?: number
tags: string[]
pinnedToView: boolean
}
/**
* Execution state for visual feedback
*/
export type ExecutionState = 'idle' | 'running' | 'success' | 'error'
/**
* The WorkflowBlock shape type for tldraw
*/
export type WorkflowBlockShape = TLBaseShape<'WorkflowBlock', WorkflowBlockProps>
// =============================================================================
// Port Binding (Arrow Connections)
// =============================================================================
/**
* Represents a connection between two ports via an arrow
*/
export interface PortBinding {
fromShapeId: TLShapeId
fromPortId: string
toShapeId: TLShapeId
toPortId: string
arrowId: TLShapeId
}
/**
* Arrow metadata for storing port binding info
*/
export interface ArrowPortMeta {
fromPortId?: string
toPortId?: string
validated?: boolean
validationError?: string
}
// =============================================================================
// Execution Types
// =============================================================================
/**
* Context passed to block executors
*/
export interface ExecutionContext {
workflowId: string
executionId: string
mode: 'manual' | 'realtime'
startTime: number
variables: Record<string, unknown>
abortSignal?: AbortSignal
}
/**
* Result from executing a single block
*/
export interface BlockExecutionResult {
blockId: TLShapeId
blockType: string
status: 'success' | 'error' | 'skipped'
outputs: Record<string, unknown>
error?: string
duration: number
startTime: number
endTime: number
}
/**
* Result from executing an entire workflow
*/
export interface WorkflowExecutionResult {
workflowId: string
executionId: string
status: 'success' | 'partial' | 'error' | 'aborted'
results: BlockExecutionResult[]
totalDuration: number
startTime: number
endTime: number
error?: string
}
// =============================================================================
// Callbacks (Flowy-compatible events)
// =============================================================================
/**
* Event callbacks for workflow interactions
*/
export interface WorkflowCallbacks {
onBlockAdd?: (block: WorkflowBlockShape) => void
onBlockRemove?: (blockId: TLShapeId) => void
onBlockUpdate?: (block: WorkflowBlockShape) => void
onConnect?: (binding: PortBinding) => void
onDisconnect?: (binding: PortBinding) => void
onValidationError?: (binding: PortBinding, error: string) => void
onExecutionStart?: (context: ExecutionContext) => void
onBlockExecute?: (result: BlockExecutionResult) => void
onExecutionComplete?: (result: WorkflowExecutionResult) => void
}
// =============================================================================
// Serialization Format
// =============================================================================
/**
* Serialized block for export/import
*/
export interface SerializedBlock {
id: string
type: string
position: { x: number; y: number }
size: { w: number; h: number }
config: Record<string, unknown>
}
/**
* Serialized connection for export/import
*/
export interface SerializedConnection {
id: string
fromBlock: string
fromPort: string
toBlock: string
toPort: string
}
/**
* Complete serialized workflow
*/
export interface SerializedWorkflow {
version: string
name: string
description?: string
blocks: SerializedBlock[]
connections: SerializedConnection[]
metadata?: {
createdAt: number
updatedAt: number
author?: string
}
}
// =============================================================================
// Block Executor Types
// =============================================================================
/**
* Function signature for block executors
*/
export type BlockExecutor = (
inputs: Record<string, unknown>,
config: Record<string, unknown>,
context: ExecutionContext
) => Promise<Record<string, unknown>>
/**
* Registry of block executors
*/
export type BlockExecutorRegistry = Record<string, BlockExecutor>
// =============================================================================
// UI State Types
// =============================================================================
/**
* State for the workflow palette UI
*/
export interface WorkflowPaletteState {
isOpen: boolean
expandedCategory: BlockCategory | null
searchQuery: string
selectedBlockType: string | null
}
/**
* State for workflow execution UI
*/
export interface WorkflowExecutionState {
isRunning: boolean
currentBlockId: TLShapeId | null
executionHistory: WorkflowExecutionResult[]
realtimeEnabled: boolean
}
// =============================================================================
// Utility Types
// =============================================================================
/**
* Check if a type is compatible with another
*/
export function isTypeCompatible(
outputType: PortDataType,
inputAccepts: PortDataType[]
): boolean {
// 'any' accepts everything
if (inputAccepts.includes('any')) return true
// Direct match
if (inputAccepts.includes(outputType)) return true
// 'any' output can go to anything
if (outputType === 'any') return true
return false
}
/**
* Get color for a port data type
*/
export function getPortTypeColor(type: PortDataType): string {
const colors: Record<PortDataType, string> = {
text: '#10b981', // Green
number: '#3b82f6', // Blue
boolean: '#8b5cf6', // Purple
object: '#f59e0b', // Amber
array: '#ec4899', // Pink
any: '#6b7280', // Gray
file: '#ef4444', // Red
image: '#06b6d4', // Cyan
}
return colors[type] || colors.any
}
/**
* Category information for UI
*/
export const CATEGORY_INFO: Record<BlockCategory, CategoryInfo> = {
trigger: {
name: 'Triggers',
icon: '⚡',
color: '#f59e0b',
description: 'Start workflows with triggers',
},
action: {
name: 'Actions',
icon: '🔧',
color: '#3b82f6',
description: 'Perform operations and API calls',
},
condition: {
name: 'Conditions',
icon: '❓',
color: '#8b5cf6',
description: 'Branch based on conditions',
},
transformer: {
name: 'Transformers',
icon: '🔄',
color: '#10b981',
description: 'Transform and manipulate data',
},
output: {
name: 'Outputs',
icon: '📤',
color: '#ef4444',
description: 'Display and export results',
},
ai: {
name: 'AI',
icon: '🤖',
color: '#ec4899',
description: 'AI and machine learning blocks',
},
}

View File

@ -0,0 +1,466 @@
/**
* Port Validation
*
* Handles type compatibility checking between ports and validates
* workflow connections to prevent invalid data flow.
*/
import {
PortDataType,
InputPort,
OutputPort,
BlockDefinition,
PortBinding,
isTypeCompatible,
} from './types'
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
// =============================================================================
// Validation Result Types
// =============================================================================
export interface ValidationResult {
valid: boolean
errors: ValidationError[]
warnings: ValidationWarning[]
}
export interface ValidationError {
type: 'type_mismatch' | 'missing_required' | 'unknown_block' | 'unknown_port' | 'cycle_detected'
message: string
blockId?: string
portId?: string
details?: Record<string, unknown>
}
export interface ValidationWarning {
type: 'implicit_conversion' | 'unused_output' | 'unconnected_input'
message: string
blockId?: string
portId?: string
}
// =============================================================================
// Port Compatibility
// =============================================================================
/**
* Check if an output port can connect to an input port
*/
export function canConnect(
outputPort: OutputPort,
inputPort: InputPort
): boolean {
return isTypeCompatible(outputPort.produces, inputPort.accepts)
}
/**
* Check if a specific type can connect to an input port
*/
export function canConnectType(
outputType: PortDataType,
inputPort: InputPort
): boolean {
return isTypeCompatible(outputType, inputPort.accepts)
}
/**
* Get all compatible ports on a target block for a given output port
*/
export function getCompatibleInputPorts(
sourceBlockType: string,
sourcePortId: string,
targetBlockType: string
): InputPort[] {
if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) {
return []
}
const sourceBlock = getBlockDefinition(sourceBlockType)
const targetBlock = getBlockDefinition(targetBlockType)
const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId)
if (!sourcePort) return []
return targetBlock.inputs.filter(inputPort =>
canConnect(sourcePort, inputPort)
)
}
/**
* Get all compatible output ports on a source block for a given input port
*/
export function getCompatibleOutputPorts(
sourceBlockType: string,
targetBlockType: string,
targetPortId: string
): OutputPort[] {
if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) {
return []
}
const sourceBlock = getBlockDefinition(sourceBlockType)
const targetBlock = getBlockDefinition(targetBlockType)
const targetPort = targetBlock.inputs.find(p => p.id === targetPortId)
if (!targetPort) return []
return sourceBlock.outputs.filter(outputPort =>
canConnect(outputPort, targetPort)
)
}
// =============================================================================
// Connection Validation
// =============================================================================
/**
* Validate a single connection between two blocks
*/
export function validateConnection(
sourceBlockType: string,
sourcePortId: string,
targetBlockType: string,
targetPortId: string
): ValidationResult {
const errors: ValidationError[] = []
const warnings: ValidationWarning[] = []
// Check source block exists
if (!hasBlockDefinition(sourceBlockType)) {
errors.push({
type: 'unknown_block',
message: `Unknown source block type: ${sourceBlockType}`,
details: { blockType: sourceBlockType },
})
return { valid: false, errors, warnings }
}
// Check target block exists
if (!hasBlockDefinition(targetBlockType)) {
errors.push({
type: 'unknown_block',
message: `Unknown target block type: ${targetBlockType}`,
details: { blockType: targetBlockType },
})
return { valid: false, errors, warnings }
}
const sourceBlock = getBlockDefinition(sourceBlockType)
const targetBlock = getBlockDefinition(targetBlockType)
// Check source port exists
const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId)
if (!sourcePort) {
errors.push({
type: 'unknown_port',
message: `Unknown output port "${sourcePortId}" on block "${sourceBlockType}"`,
portId: sourcePortId,
details: { blockType: sourceBlockType, availablePorts: sourceBlock.outputs.map(p => p.id) },
})
return { valid: false, errors, warnings }
}
// Check target port exists
const targetPort = targetBlock.inputs.find(p => p.id === targetPortId)
if (!targetPort) {
errors.push({
type: 'unknown_port',
message: `Unknown input port "${targetPortId}" on block "${targetBlockType}"`,
portId: targetPortId,
details: { blockType: targetBlockType, availablePorts: targetBlock.inputs.map(p => p.id) },
})
return { valid: false, errors, warnings }
}
// Check type compatibility
if (!canConnect(sourcePort, targetPort)) {
errors.push({
type: 'type_mismatch',
message: `Type mismatch: "${sourcePort.produces}" cannot connect to "${targetPort.accepts.join(' | ')}"`,
details: {
sourceType: sourcePort.produces,
targetAccepts: targetPort.accepts,
sourcePort: sourcePortId,
targetPort: targetPortId,
},
})
return { valid: false, errors, warnings }
}
// Check for implicit conversions (warning, not error)
if (sourcePort.produces !== targetPort.type && targetPort.accepts.includes('any')) {
warnings.push({
type: 'implicit_conversion',
message: `Implicit conversion from "${sourcePort.produces}" to "${targetPort.type}"`,
})
}
return { valid: true, errors, warnings }
}
/**
* Validate a port binding
*/
export function validatePortBinding(
binding: PortBinding,
getBlockType: (shapeId: string) => string | undefined
): ValidationResult {
const sourceType = getBlockType(binding.fromShapeId as string)
const targetType = getBlockType(binding.toShapeId as string)
if (!sourceType || !targetType) {
return {
valid: false,
errors: [{
type: 'unknown_block',
message: 'Could not determine block types for binding',
blockId: !sourceType ? binding.fromShapeId as string : binding.toShapeId as string,
}],
warnings: [],
}
}
return validateConnection(
sourceType,
binding.fromPortId,
targetType,
binding.toPortId
)
}
// =============================================================================
// Block Validation
// =============================================================================
/**
* Validate a block's configuration
*/
export function validateBlockConfig(
blockType: string,
config: Record<string, unknown>
): ValidationResult {
const errors: ValidationError[] = []
const warnings: ValidationWarning[] = []
if (!hasBlockDefinition(blockType)) {
errors.push({
type: 'unknown_block',
message: `Unknown block type: ${blockType}`,
})
return { valid: false, errors, warnings }
}
const definition = getBlockDefinition(blockType)
// If no config schema, any config is valid
if (!definition.configSchema) {
return { valid: true, errors, warnings }
}
// Basic schema validation (could use ajv for full JSON Schema validation)
const schema = definition.configSchema as { properties?: Record<string, unknown> }
if (schema.properties) {
for (const [key, propSchema] of Object.entries(schema.properties)) {
const prop = propSchema as { type?: string; required?: boolean; enum?: unknown[] }
// Check required properties
if (prop.required && !(key in config)) {
errors.push({
type: 'missing_required',
message: `Missing required configuration: ${key}`,
details: { key },
})
}
// Check enum values
if (prop.enum && key in config && !prop.enum.includes(config[key])) {
errors.push({
type: 'type_mismatch',
message: `Invalid value for "${key}": must be one of ${prop.enum.join(', ')}`,
details: { key, value: config[key], allowed: prop.enum },
})
}
}
}
return { valid: errors.length === 0, errors, warnings }
}
/**
* Check if a block has all required inputs satisfied
*/
export function validateRequiredInputs(
blockType: string,
inputValues: Record<string, unknown>,
connectedInputs: string[]
): ValidationResult {
const errors: ValidationError[] = []
const warnings: ValidationWarning[] = []
if (!hasBlockDefinition(blockType)) {
errors.push({
type: 'unknown_block',
message: `Unknown block type: ${blockType}`,
})
return { valid: false, errors, warnings }
}
const definition = getBlockDefinition(blockType)
for (const input of definition.inputs) {
if (input.required) {
const hasValue = input.id in inputValues && inputValues[input.id] !== undefined
const hasConnection = connectedInputs.includes(input.id)
if (!hasValue && !hasConnection) {
errors.push({
type: 'missing_required',
message: `Required input "${input.name}" is not connected or provided`,
portId: input.id,
})
}
}
}
// Warn about unconnected optional inputs
for (const input of definition.inputs) {
if (!input.required) {
const hasValue = input.id in inputValues && inputValues[input.id] !== undefined
const hasConnection = connectedInputs.includes(input.id)
if (!hasValue && !hasConnection && input.defaultValue === undefined) {
warnings.push({
type: 'unconnected_input',
message: `Optional input "${input.name}" has no value or connection`,
portId: input.id,
})
}
}
}
return { valid: errors.length === 0, errors, warnings }
}
// =============================================================================
// Workflow Validation
// =============================================================================
/**
* Detect cycles in a workflow graph
*/
export function detectCycles(
connections: PortBinding[]
): { hasCycle: boolean; cycleNodes?: string[] } {
// Build adjacency list
const graph = new Map<string, Set<string>>()
for (const conn of connections) {
const from = conn.fromShapeId as string
const to = conn.toShapeId as string
if (!graph.has(from)) graph.set(from, new Set())
graph.get(from)!.add(to)
}
// DFS to detect cycles
const visited = new Set<string>()
const recursionStack = new Set<string>()
const cyclePath: string[] = []
function dfs(node: string): boolean {
visited.add(node)
recursionStack.add(node)
cyclePath.push(node)
const neighbors = graph.get(node) || new Set()
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
if (dfs(neighbor)) return true
} else if (recursionStack.has(neighbor)) {
cyclePath.push(neighbor)
return true
}
}
cyclePath.pop()
recursionStack.delete(node)
return false
}
for (const node of graph.keys()) {
if (!visited.has(node)) {
if (dfs(node)) {
// Extract just the cycle portion
const cycleStart = cyclePath.indexOf(cyclePath[cyclePath.length - 1])
return {
hasCycle: true,
cycleNodes: cyclePath.slice(cycleStart),
}
}
}
}
return { hasCycle: false }
}
/**
* Validate an entire workflow
*/
export function validateWorkflow(
blocks: Array<{ id: string; blockType: string; config: Record<string, unknown> }>,
connections: PortBinding[]
): ValidationResult {
const errors: ValidationError[] = []
const warnings: ValidationWarning[] = []
// Validate each connection
for (const conn of connections) {
const sourceBlock = blocks.find(b => b.id === conn.fromShapeId)
const targetBlock = blocks.find(b => b.id === conn.toShapeId)
if (sourceBlock && targetBlock) {
const result = validateConnection(
sourceBlock.blockType,
conn.fromPortId,
targetBlock.blockType,
conn.toPortId
)
errors.push(...result.errors)
warnings.push(...result.warnings)
}
}
// Check for cycles
const cycleResult = detectCycles(connections)
if (cycleResult.hasCycle) {
errors.push({
type: 'cycle_detected',
message: `Cycle detected in workflow: ${cycleResult.cycleNodes?.join(' -> ')}`,
details: { cycleNodes: cycleResult.cycleNodes },
})
}
// Check for unused outputs (optional warning)
const connectedOutputs = new Set(connections.map(c => `${c.fromShapeId}:${c.fromPortId}`))
for (const block of blocks) {
if (!hasBlockDefinition(block.blockType)) continue
const def = getBlockDefinition(block.blockType)
for (const output of def.outputs) {
if (!connectedOutputs.has(`${block.id}:${output.id}`)) {
// Only warn for non-terminal blocks
if (def.category !== 'output') {
warnings.push({
type: 'unused_output',
message: `Output "${output.name}" on block "${def.name}" is not connected`,
blockId: block.id,
portId: output.id,
})
}
}
}
}
return { valid: errors.length === 0, errors, warnings }
}

View File

@ -0,0 +1,404 @@
/**
* WorkflowPropagator
*
* A propagator that handles real-time data flow between workflow blocks.
* When a workflow block's output changes, it automatically propagates
* the data to connected downstream blocks and triggers their execution.
*
* Uses the 'flow' prefix for arrows (e.g., flow{ ... } in arrow text)
* to identify workflow connections.
*/
import {
Editor,
TLArrowBinding,
TLArrowShape,
TLShape,
TLShapeId,
} from 'tldraw'
import { getEdge, getArrowsFromShape } from './tlgraph'
import { isShapeOfType } from './utils'
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
import {
getPortBinding,
getBlockOutputBindings,
getDownstreamBlocks,
} from '@/lib/workflow/portBindings'
import { executeBlock } from '@/lib/workflow/executor'
import { canConnect } from '@/lib/workflow/validation'
import { getBlockDefinition, hasBlockDefinition } from '@/lib/workflow/blockRegistry'
// =============================================================================
// Configuration
// =============================================================================
/**
* Whether to auto-execute downstream blocks when outputs change
*/
let autoExecuteEnabled = true
/**
* Debounce time for propagation (ms)
*/
const PROPAGATION_DEBOUNCE = 100
/**
* Enable/disable auto-execution
*/
export function setAutoExecute(enabled: boolean): void {
autoExecuteEnabled = enabled
}
/**
* Get auto-execution status
*/
export function isAutoExecuteEnabled(): boolean {
return autoExecuteEnabled
}
// =============================================================================
// Propagator State
// =============================================================================
interface PropagatorState {
editor: Editor | null
watchedBlocks: Set<TLShapeId>
pendingPropagations: Map<TLShapeId, NodeJS.Timeout>
executingBlocks: Set<TLShapeId>
}
const state: PropagatorState = {
editor: null,
watchedBlocks: new Set(),
pendingPropagations: new Map(),
executingBlocks: new Set(),
}
// =============================================================================
// Propagator Functions
// =============================================================================
/**
* Check if a shape is a workflow block
*/
function isWorkflowBlock(shape: TLShape | undefined): shape is IWorkflowBlock {
return shape?.type === 'WorkflowBlock'
}
/**
* Check if an arrow is a workflow connection (connects two workflow blocks)
*/
function isWorkflowArrow(editor: Editor, arrowId: TLShapeId): boolean {
const arrow = editor.getShape(arrowId) as TLArrowShape | undefined
if (!arrow || arrow.type !== 'arrow') return false
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(arrowId)
if (bindings.length !== 2) return false
const startBinding = bindings.find(b => b.props.terminal === 'start')
const endBinding = bindings.find(b => b.props.terminal === 'end')
if (!startBinding || !endBinding) return false
const startShape = editor.getShape(startBinding.toId)
const endShape = editor.getShape(endBinding.toId)
return isWorkflowBlock(startShape) && isWorkflowBlock(endShape)
}
/**
* Propagate output data from a block to its downstream connections
*/
async function propagateOutputs(
editor: Editor,
sourceBlockId: TLShapeId
): Promise<void> {
if (!autoExecuteEnabled) return
if (state.executingBlocks.has(sourceBlockId)) return
const sourceShape = editor.getShape(sourceBlockId) as IWorkflowBlock | undefined
if (!sourceShape || !isWorkflowBlock(sourceShape)) return
const outputBindings = getBlockOutputBindings(editor, sourceBlockId)
const downstreamBlocks = new Set<TLShapeId>()
// Collect downstream blocks and update their input values
for (const binding of outputBindings) {
const outputValue = sourceShape.props.outputValues?.[binding.fromPortId]
if (outputValue !== undefined) {
// Update the target block's input value
const targetShape = editor.getShape(binding.toShapeId) as IWorkflowBlock | undefined
if (targetShape && isWorkflowBlock(targetShape)) {
editor.updateShape<IWorkflowBlock>({
id: binding.toShapeId,
type: 'WorkflowBlock',
props: {
inputValues: {
...targetShape.props.inputValues,
[binding.toPortId]: outputValue,
},
},
})
downstreamBlocks.add(binding.toShapeId)
}
}
}
// Execute downstream blocks if auto-execute is enabled
for (const blockId of downstreamBlocks) {
// Skip blocks that are already executing
if (state.executingBlocks.has(blockId)) continue
const blockShape = editor.getShape(blockId) as IWorkflowBlock | undefined
if (!blockShape) continue
// Check if block has all required inputs satisfied
if (hasBlockDefinition(blockShape.props.blockType)) {
const definition = getBlockDefinition(blockShape.props.blockType)
const requiredInputs = definition.inputs.filter(i => i.required)
const hasAllRequired = requiredInputs.every(input => {
const inputValue = blockShape.props.inputValues?.[input.id]
return inputValue !== undefined
})
if (hasAllRequired) {
// Debounce execution to avoid rapid-fire updates
const existingTimeout = state.pendingPropagations.get(blockId)
if (existingTimeout) clearTimeout(existingTimeout)
const timeout = setTimeout(async () => {
state.pendingPropagations.delete(blockId)
state.executingBlocks.add(blockId)
try {
await executeBlock(editor, blockId)
} finally {
state.executingBlocks.delete(blockId)
}
}, PROPAGATION_DEBOUNCE)
state.pendingPropagations.set(blockId, timeout)
}
}
}
}
/**
* Handle workflow block changes
*/
function onBlockChange(editor: Editor, shape: TLShape): void {
if (!isWorkflowBlock(shape)) return
// Check if output values changed
const oldShape = editor.store.query.record('shape', () => shape.id)
if (oldShape && isWorkflowBlock(oldShape as TLShape)) {
const oldOutputs = (oldShape as IWorkflowBlock).props.outputValues
const newOutputs = shape.props.outputValues
// Only propagate if outputs actually changed
if (JSON.stringify(oldOutputs) !== JSON.stringify(newOutputs)) {
propagateOutputs(editor, shape.id)
}
}
}
/**
* Handle arrow changes to update port bindings
*/
function onArrowChange(editor: Editor, arrow: TLArrowShape): void {
if (!isWorkflowArrow(editor, arrow.id)) return
const edge = getEdge(arrow, editor)
if (!edge) return
const fromShape = editor.getShape(edge.from) as IWorkflowBlock | undefined
const toShape = editor.getShape(edge.to) as IWorkflowBlock | undefined
if (!fromShape || !toShape) return
if (!isWorkflowBlock(fromShape) || !isWorkflowBlock(toShape)) return
// Determine port IDs based on arrow position or existing meta
const meta = (arrow.meta || {}) as { fromPortId?: string; toPortId?: string }
// If meta already has port IDs, validate the connection
if (meta.fromPortId && meta.toPortId) {
if (!hasBlockDefinition(fromShape.props.blockType) ||
!hasBlockDefinition(toShape.props.blockType)) {
return
}
const fromDef = getBlockDefinition(fromShape.props.blockType)
const toDef = getBlockDefinition(toShape.props.blockType)
const fromPort = fromDef.outputs.find(p => p.id === meta.fromPortId)
const toPort = toDef.inputs.find(p => p.id === meta.toPortId)
if (fromPort && toPort && canConnect(fromPort, toPort)) {
// Valid connection - update arrow color to indicate valid
editor.updateShape({
id: arrow.id,
type: 'arrow',
props: { color: 'black' },
})
} else {
// Invalid connection
editor.updateShape({
id: arrow.id,
type: 'arrow',
props: { color: 'orange' },
})
}
} else {
// Auto-detect ports based on first available compatible pair
if (!hasBlockDefinition(fromShape.props.blockType) ||
!hasBlockDefinition(toShape.props.blockType)) {
return
}
const fromDef = getBlockDefinition(fromShape.props.blockType)
const toDef = getBlockDefinition(toShape.props.blockType)
// Find first compatible port pair
for (const outputPort of fromDef.outputs) {
for (const inputPort of toDef.inputs) {
if (canConnect(outputPort, inputPort)) {
// Set port binding on arrow meta
editor.updateShape({
id: arrow.id,
type: 'arrow',
meta: {
...arrow.meta,
fromPortId: outputPort.id,
toPortId: inputPort.id,
validated: true,
},
props: { color: 'black' },
})
return
}
}
}
// No compatible ports found
editor.updateShape({
id: arrow.id,
type: 'arrow',
props: { color: 'orange' },
})
}
}
// =============================================================================
// Registration
// =============================================================================
/**
* Register the workflow propagator with the editor
*/
export function registerWorkflowPropagator(editor: Editor): () => void {
state.editor = editor
state.watchedBlocks.clear()
state.pendingPropagations.clear()
state.executingBlocks.clear()
// Initialize: find all existing workflow blocks
for (const shape of editor.getCurrentPageShapes()) {
if (isWorkflowBlock(shape)) {
state.watchedBlocks.add(shape.id)
}
}
// Register change handler
const unsubscribeChange = editor.sideEffects.registerAfterChangeHandler<'shape'>(
'shape',
(_, next) => {
// Handle workflow block changes
if (isWorkflowBlock(next)) {
state.watchedBlocks.add(next.id)
onBlockChange(editor, next)
}
// Handle arrow changes
if (isShapeOfType<TLArrowShape>(next, 'arrow')) {
onArrowChange(editor, next)
}
}
)
// Register create handler
const unsubscribeCreate = editor.sideEffects.registerAfterCreateHandler<'shape'>(
'shape',
(shape) => {
if (isWorkflowBlock(shape)) {
state.watchedBlocks.add(shape.id)
}
}
)
// Register delete handler
const unsubscribeDelete = editor.sideEffects.registerAfterDeleteHandler<'shape'>(
'shape',
(shape) => {
if (shape.type === 'WorkflowBlock') {
state.watchedBlocks.delete(shape.id)
state.pendingPropagations.delete(shape.id)
state.executingBlocks.delete(shape.id)
}
}
)
// Register binding change handler for arrows
const unsubscribeBinding = editor.sideEffects.registerAfterChangeHandler<'binding'>(
'binding',
(_, binding) => {
if (binding.type !== 'arrow') return
const arrow = editor.getShape(binding.fromId)
if (arrow && isShapeOfType<TLArrowShape>(arrow, 'arrow')) {
onArrowChange(editor, arrow)
}
}
)
// Return cleanup function
return () => {
unsubscribeChange()
unsubscribeCreate()
unsubscribeDelete()
unsubscribeBinding()
// Clear pending propagations
for (const timeout of state.pendingPropagations.values()) {
clearTimeout(timeout)
}
state.editor = null
state.watchedBlocks.clear()
state.pendingPropagations.clear()
state.executingBlocks.clear()
}
}
/**
* Manually trigger propagation from a block
*/
export function triggerPropagation(
editor: Editor,
blockId: TLShapeId
): Promise<void> {
return propagateOutputs(editor, blockId)
}
/**
* Get all blocks currently being watched
*/
export function getWatchedBlocks(): TLShapeId[] {
return Array.from(state.watchedBlocks)
}
/**
* Check if a block is currently executing
*/
export function isBlockExecuting(blockId: TLShapeId): boolean {
return state.executingBlocks.has(blockId)
}

View File

@ -93,7 +93,8 @@ export class CalendarEventShape extends BaseBoxShapeUtil<ICalendarEventShape> {
}
component(shape: ICalendarEventShape) {
const { w, h, props } = shape
const { props } = shape
const { w, h } = props
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
// Detect dark mode

View File

@ -0,0 +1,539 @@
/**
* WorkflowBlockShapeUtil
*
* A visual workflow block shape with typed input/output ports.
* Supports connection to other blocks via tldraw arrows for
* building automation flows, data pipelines, and AI agent chains.
*/
import {
BaseBoxShapeUtil,
Geometry2d,
HTMLContainer,
Rectangle2d,
TLBaseShape,
Vec,
} from 'tldraw'
import React, { useState, useCallback, useMemo } from 'react'
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
import { usePinnedToView } from '../hooks/usePinnedToView'
import { useMaximize } from '../hooks/useMaximize'
import {
WorkflowBlockProps,
ExecutionState,
getPortTypeColor,
CATEGORY_INFO,
} from '../lib/workflow/types'
import {
getBlockDefinition,
hasBlockDefinition,
} from '../lib/workflow/blockRegistry'
// =============================================================================
// Shape Type Definition
// =============================================================================
export type IWorkflowBlock = TLBaseShape<'WorkflowBlock', WorkflowBlockProps>
// =============================================================================
// Constants
// =============================================================================
const PORT_SIZE = 12
const PORT_SPACING = 28
const HEADER_HEIGHT = 36
const MIN_WIDTH = 180
const MIN_HEIGHT = 100
const DEFAULT_WIDTH = 220
const DEFAULT_HEIGHT = 150
// =============================================================================
// Execution State Colors
// =============================================================================
const EXECUTION_COLORS: Record<ExecutionState, { bg: string; border: string; text: string }> = {
idle: { bg: 'transparent', border: 'transparent', text: '#6b7280' },
running: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
success: { bg: '#d1fae5', border: '#10b981', text: '#065f46' },
error: { bg: '#fee2e2', border: '#ef4444', text: '#991b1b' },
}
// =============================================================================
// Port Renderer Component
// =============================================================================
interface PortProps {
port: { id: string; name: string; type: string; required?: boolean }
direction: 'input' | 'output'
index: number
shapeWidth: number
isConnected?: boolean
onHover?: (portId: string | null) => void
}
const Port: React.FC<PortProps> = ({
port,
direction,
index,
shapeWidth,
isConnected = false,
onHover,
}) => {
const [isHovered, setIsHovered] = useState(false)
const color = getPortTypeColor(port.type as any)
const x = direction === 'input' ? -PORT_SIZE / 2 : shapeWidth - PORT_SIZE / 2
const y = HEADER_HEIGHT + 12 + index * PORT_SPACING
const handleMouseEnter = () => {
setIsHovered(true)
onHover?.(port.id)
}
const handleMouseLeave = () => {
setIsHovered(false)
onHover?.(null)
}
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width: PORT_SIZE,
height: PORT_SIZE,
borderRadius: '50%',
backgroundColor: isConnected ? color : 'white',
border: `2px solid ${color}`,
cursor: 'crosshair',
transform: isHovered ? 'scale(1.3)' : 'scale(1)',
transition: 'transform 0.15s ease, background-color 0.15s ease',
zIndex: 10,
boxShadow: isHovered ? `0 0 8px ${color}` : 'none',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
title={`${port.name} (${port.type})${port.required ? ' *' : ''}`}
data-port-id={port.id}
data-port-direction={direction}
data-port-type={port.type}
/>
)
}
// =============================================================================
// Port Label Component
// =============================================================================
interface PortLabelProps {
port: { id: string; name: string; type: string }
direction: 'input' | 'output'
index: number
shapeWidth: number
}
const PortLabel: React.FC<PortLabelProps> = ({ port, direction, index, shapeWidth }) => {
const y = HEADER_HEIGHT + 12 + index * PORT_SPACING
const color = getPortTypeColor(port.type as any)
return (
<div
style={{
position: 'absolute',
left: direction === 'input' ? PORT_SIZE + 4 : 'auto',
right: direction === 'output' ? PORT_SIZE + 4 : 'auto',
top: y - 3,
fontSize: '11px',
color: '#4b5563',
whiteSpace: 'nowrap',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
{direction === 'output' && (
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: color,
opacity: 0.6,
}}
/>
)}
<span>{port.name}</span>
{direction === 'input' && (
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: color,
opacity: 0.6,
}}
/>
)}
</div>
)
}
// =============================================================================
// Main Shape Util Class
// =============================================================================
export class WorkflowBlockShapeUtil extends BaseBoxShapeUtil<IWorkflowBlock> {
static override type = 'WorkflowBlock' as const
// Workflow blocks use indigo as base, but category determines actual color
static readonly PRIMARY_COLOR = '#6366f1'
getDefaultProps(): IWorkflowBlock['props'] {
return {
w: DEFAULT_WIDTH,
h: DEFAULT_HEIGHT,
blockType: 'trigger.manual',
blockConfig: {},
inputValues: {},
outputValues: {},
executionState: 'idle',
tags: ['workflow'],
pinnedToView: false,
}
}
getGeometry(shape: IWorkflowBlock): Geometry2d {
return new Rectangle2d({
width: Math.max(shape.props.w, MIN_WIDTH),
height: Math.max(shape.props.h, MIN_HEIGHT),
isFilled: true,
})
}
/**
* Get the position of a port in shape-local coordinates.
* Used for arrow snapping.
*/
getPortPosition(shape: IWorkflowBlock, portId: string, direction: 'input' | 'output'): Vec {
if (!hasBlockDefinition(shape.props.blockType)) {
return new Vec(0, HEADER_HEIGHT + 20)
}
const definition = getBlockDefinition(shape.props.blockType)
const ports = direction === 'input' ? definition.inputs : definition.outputs
const portIndex = ports.findIndex(p => p.id === portId)
if (portIndex === -1) {
return new Vec(0, HEADER_HEIGHT + 20)
}
const x = direction === 'input' ? 0 : shape.props.w
const y = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2
return new Vec(x, y)
}
component(shape: IWorkflowBlock) {
const { blockType, executionState, executionError, blockConfig } = shape.props
// Get block definition
const definition = useMemo(() => {
if (!hasBlockDefinition(blockType)) {
return null
}
return getBlockDefinition(blockType)
}, [blockType])
// Determine colors based on category
const categoryColor = definition
? CATEGORY_INFO[definition.category].color
: WorkflowBlockShapeUtil.PRIMARY_COLOR
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [hoveredPort, setHoveredPort] = useState<string | null>(null)
// Pin to view functionality
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView, { position: 'current' })
// Maximize functionality
const { isMaximized, toggleMaximize } = useMaximize({
editor: this.editor,
shapeId: shape.id,
currentW: shape.props.w,
currentH: shape.props.h,
shapeType: 'WorkflowBlock',
padding: 40,
})
// Handlers
const handleClose = useCallback(() => {
this.editor.deleteShapes([shape.id])
}, [shape.id])
const handlePinToggle = useCallback(() => {
this.editor.updateShape<IWorkflowBlock>({
id: shape.id,
type: 'WorkflowBlock',
props: { pinnedToView: !shape.props.pinnedToView },
})
}, [shape.id, shape.props.pinnedToView])
const handleTagsChange = useCallback((newTags: string[]) => {
this.editor.updateShape<IWorkflowBlock>({
id: shape.id,
type: 'WorkflowBlock',
props: { tags: newTags },
})
}, [shape.id])
const handleRunBlock = useCallback(() => {
// Trigger manual execution (will be handled by executor)
this.editor.updateShape<IWorkflowBlock>({
id: shape.id,
type: 'WorkflowBlock',
props: { executionState: 'running' },
})
// Dispatch custom event for executor to pick up
window.dispatchEvent(new CustomEvent('workflow:execute-block', {
detail: { blockId: shape.id },
}))
}, [shape.id])
// If block type is unknown, show error state
if (!definition) {
return (
<HTMLContainer
style={{
width: shape.props.w,
height: shape.props.h,
pointerEvents: 'all',
}}
>
<div
style={{
width: '100%',
height: '100%',
backgroundColor: '#fee2e2',
border: '2px solid #ef4444',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#991b1b',
fontSize: 12,
padding: 16,
textAlign: 'center',
}}
>
Unknown block type: {blockType}
</div>
</HTMLContainer>
)
}
const executionColors = EXECUTION_COLORS[executionState]
// Calculate minimum height based on ports
const maxPorts = Math.max(definition.inputs.length, definition.outputs.length)
const calculatedHeight = Math.max(
shape.props.h,
HEADER_HEIGHT + 24 + maxPorts * PORT_SPACING + 40
)
return (
<HTMLContainer
style={{
width: shape.props.w,
height: calculatedHeight,
pointerEvents: 'all',
}}
>
<StandardizedToolWrapper
title={definition.name}
primaryColor={categoryColor}
isSelected={isSelected}
width={shape.props.w}
height={calculatedHeight}
onClose={handleClose}
onMaximize={toggleMaximize}
isMaximized={isMaximized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={handleTagsChange}
tagsEditable={true}
headerContent={
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 14 }}>{definition.icon}</span>
</div>
}
>
{/* Execution state indicator */}
{executionState !== 'idle' && (
<div
style={{
position: 'absolute',
top: HEADER_HEIGHT + 4,
right: 8,
padding: '2px 8px',
borderRadius: 4,
backgroundColor: executionColors.bg,
border: `1px solid ${executionColors.border}`,
color: executionColors.text,
fontSize: 10,
fontWeight: 500,
textTransform: 'uppercase',
}}
>
{executionState === 'running' && '⏳ Running'}
{executionState === 'success' && '✓ Done'}
{executionState === 'error' && '✕ Error'}
</div>
)}
{/* Block description */}
<div
style={{
padding: '8px 12px',
fontSize: 11,
color: '#6b7280',
borderBottom: '1px solid #e5e7eb',
}}
>
{definition.description}
</div>
{/* Ports container */}
<div
style={{
position: 'relative',
flex: 1,
minHeight: maxPorts * PORT_SPACING + 24,
}}
>
{/* Input ports */}
{definition.inputs.map((port, index) => (
<React.Fragment key={`input-${port.id}`}>
<Port
port={port}
direction="input"
index={index}
shapeWidth={shape.props.w}
onHover={setHoveredPort}
/>
<PortLabel
port={port}
direction="input"
index={index}
shapeWidth={shape.props.w}
/>
</React.Fragment>
))}
{/* Output ports */}
{definition.outputs.map((port, index) => (
<React.Fragment key={`output-${port.id}`}>
<Port
port={port}
direction="output"
index={index}
shapeWidth={shape.props.w}
onHover={setHoveredPort}
/>
<PortLabel
port={port}
direction="output"
index={index}
shapeWidth={shape.props.w}
/>
</React.Fragment>
))}
</div>
{/* Error message */}
{executionError && (
<div
style={{
padding: '8px 12px',
backgroundColor: '#fee2e2',
color: '#991b1b',
fontSize: 11,
borderTop: '1px solid #fecaca',
}}
>
{executionError}
</div>
)}
{/* Run button for trigger blocks */}
{definition.category === 'trigger' && (
<div
style={{
padding: '8px 12px',
borderTop: '1px solid #e5e7eb',
}}
>
<button
onClick={handleRunBlock}
disabled={executionState === 'running'}
style={{
width: '100%',
padding: '6px 12px',
backgroundColor: executionState === 'running' ? '#9ca3af' : categoryColor,
color: 'white',
border: 'none',
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
cursor: executionState === 'running' ? 'not-allowed' : 'pointer',
transition: 'background-color 0.15s ease',
}}
onMouseEnter={(e) => {
if (executionState !== 'running') {
e.currentTarget.style.opacity = '0.9'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1'
}}
>
{executionState === 'running' ? 'Running...' : '▶ Run'}
</button>
</div>
)}
</StandardizedToolWrapper>
</HTMLContainer>
)
}
indicator(shape: IWorkflowBlock) {
// Calculate height same as component
const definition = hasBlockDefinition(shape.props.blockType)
? getBlockDefinition(shape.props.blockType)
: null
const maxPorts = definition
? Math.max(definition.inputs.length, definition.outputs.length)
: 0
const calculatedHeight = Math.max(
shape.props.h,
HEADER_HEIGHT + 24 + maxPorts * PORT_SPACING + 40
)
return (
<rect
width={Math.max(shape.props.w, MIN_WIDTH)}
height={calculatedHeight}
rx={8}
/>
)
}
}
// Export the shape for registration
export const WorkflowBlockShape = WorkflowBlockShapeUtil

View File

@ -0,0 +1,204 @@
/**
* WorkflowBlockTool
*
* A StateNode-based tool for placing workflow blocks on the canvas.
* Shows a tooltip with the block type and creates the block on click.
*/
import { StateNode, TLEventHandlers } from 'tldraw'
import { findNonOverlappingPosition } from '@/utils/shapeCollisionUtils'
import { getBlockDefinition, hasBlockDefinition } from '@/lib/workflow/blockRegistry'
import { CATEGORY_INFO } from '@/lib/workflow/types'
// Store the selected block type for creation
let selectedBlockType = 'trigger.manual'
/**
* Set the block type that will be created when clicking
*/
export function setWorkflowBlockType(blockType: string): void {
selectedBlockType = blockType
}
/**
* Get the currently selected block type
*/
export function getWorkflowBlockType(): string {
return selectedBlockType
}
/**
* Main WorkflowBlock tool
*/
export class WorkflowBlockTool extends StateNode {
static override id = 'WorkflowBlock'
static override initial = 'idle'
static override children = () => [WorkflowBlockIdle]
}
/**
* Idle state - shows tooltip and handles click to create block
*/
export class WorkflowBlockIdle extends StateNode {
static override id = 'idle'
tooltipElement?: HTMLDivElement
mouseMoveHandler?: (e: MouseEvent) => void
override onEnter = () => {
// Set cursor to cross
this.editor.setCursor({ type: 'cross', rotation: 0 })
// Get block info for tooltip
const blockType = getWorkflowBlockType()
const definition = hasBlockDefinition(blockType)
? getBlockDefinition(blockType)
: null
const blockName = definition?.name || 'Workflow Block'
const categoryInfo = definition ? CATEGORY_INFO[definition.category] : null
const icon = definition?.icon || '📦'
// Create tooltip element
this.tooltipElement = document.createElement('div')
this.tooltipElement.style.cssText = `
position: fixed;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 14px;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
white-space: nowrap;
z-index: 10000;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 8px;
`
// Add colored category indicator
if (categoryInfo) {
const indicator = document.createElement('span')
indicator.style.cssText = `
width: 8px;
height: 8px;
border-radius: 50%;
background: ${categoryInfo.color};
`
this.tooltipElement.appendChild(indicator)
}
// Add icon and text
const textSpan = document.createElement('span')
textSpan.textContent = `${icon} Click to place ${blockName}`
this.tooltipElement.appendChild(textSpan)
// Add tooltip to DOM
document.body.appendChild(this.tooltipElement)
// Update tooltip position on mouse move
this.mouseMoveHandler = (e: MouseEvent) => {
if (this.tooltipElement) {
const x = e.clientX + 15
const y = e.clientY - 40
// Keep tooltip within viewport bounds
const rect = this.tooltipElement.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let finalX = x
let finalY = y
// Adjust if tooltip would go off edges
if (x + rect.width > viewportWidth) {
finalX = e.clientX - rect.width - 15
}
if (y + rect.height > viewportHeight) {
finalY = e.clientY - rect.height - 15
}
finalX = Math.max(10, finalX)
finalY = Math.max(10, finalY)
this.tooltipElement.style.left = `${finalX}px`
this.tooltipElement.style.top = `${finalY}px`
}
}
document.addEventListener('mousemove', this.mouseMoveHandler)
}
override onPointerDown: TLEventHandlers['onPointerDown'] = () => {
const { currentPagePoint } = this.editor.inputs
this.createWorkflowBlock(currentPagePoint.x, currentPagePoint.y)
}
override onExit = () => {
this.cleanupTooltip()
}
private cleanupTooltip = () => {
if (this.mouseMoveHandler) {
document.removeEventListener('mousemove', this.mouseMoveHandler)
this.mouseMoveHandler = undefined
}
if (this.tooltipElement && this.tooltipElement.parentNode) {
this.tooltipElement.parentNode.removeChild(this.tooltipElement)
this.tooltipElement = undefined
}
}
private createWorkflowBlock(clickX: number, clickY: number) {
try {
const blockType = getWorkflowBlockType()
const definition = hasBlockDefinition(blockType)
? getBlockDefinition(blockType)
: null
// Calculate size based on ports
const shapeWidth = 220
const maxPorts = definition
? Math.max(definition.inputs.length, definition.outputs.length)
: 2
const shapeHeight = Math.max(150, 36 + 24 + maxPorts * 28 + 60)
// Center the shape on click
const finalX = clickX - shapeWidth / 2
const finalY = clickY - shapeHeight / 2
// Create the shape
const shape = this.editor.createShape({
type: 'WorkflowBlock',
x: finalX,
y: finalY,
props: {
w: shapeWidth,
h: shapeHeight,
blockType: blockType,
blockConfig: definition?.defaultConfig || {},
inputValues: {},
outputValues: {},
executionState: 'idle',
tags: ['workflow', definition?.category || 'block'],
pinnedToView: false,
},
})
// Select the new shape and switch to select tool
if (shape) {
this.editor.setSelectedShapes([shape.id])
}
this.editor.setCurrentTool('select')
} catch (error) {
console.error('Error creating WorkflowBlock shape:', error)
}
}
}
export default WorkflowBlockTool