feat: add Flowy-like workflow builder system
Implements a visual workflow builder with: - WorkflowBlockShapeUtil: Visual blocks with typed input/output ports - WorkflowBlockTool: Click-to-place tool for adding blocks - Block registry with 20+ blocks (triggers, actions, conditions, transformers, AI, outputs) - Port validation and type compatibility checking - WorkflowPropagator for real-time data flow between connected blocks - Workflow executor for manual execution with topological ordering - WorkflowPalette UI sidebar with searchable block categories - JSON serialization for workflow export/import - Workflow templates (API request, LLM chain, conditional) Blocks are accessible via "Workflow Blocks" button in toolbar dropdown. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a938b38d1f
commit
5fc505f1fc
|
|
@ -0,0 +1,273 @@
|
||||||
|
/**
|
||||||
|
* WorkflowPalette
|
||||||
|
*
|
||||||
|
* Sidebar palette showing available workflow blocks organized by category.
|
||||||
|
* Supports click-to-place and displays block descriptions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react'
|
||||||
|
import { Editor } from 'tldraw'
|
||||||
|
import {
|
||||||
|
getAllBlockDefinitions,
|
||||||
|
getBlocksByCategory,
|
||||||
|
} from '@/lib/workflow/blockRegistry'
|
||||||
|
import {
|
||||||
|
BlockCategory,
|
||||||
|
BlockDefinition,
|
||||||
|
CATEGORY_INFO,
|
||||||
|
} from '@/lib/workflow/types'
|
||||||
|
import {
|
||||||
|
setWorkflowBlockType,
|
||||||
|
} from '@/tools/WorkflowBlockTool'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface WorkflowPaletteProps {
|
||||||
|
editor: Editor
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Category Section Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface CategorySectionProps {
|
||||||
|
category: BlockCategory
|
||||||
|
blocks: BlockDefinition[]
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onBlockClick: (blockType: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategorySection: React.FC<CategorySectionProps> = ({
|
||||||
|
category,
|
||||||
|
blocks,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
onBlockClick,
|
||||||
|
}) => {
|
||||||
|
const info = CATEGORY_INFO[category]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workflow-palette-category">
|
||||||
|
<button
|
||||||
|
className="workflow-palette-category-header"
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{ borderLeftColor: info.color }}
|
||||||
|
>
|
||||||
|
<span className="workflow-palette-category-icon">{info.icon}</span>
|
||||||
|
<span className="workflow-palette-category-label">{info.label}</span>
|
||||||
|
<span className="workflow-palette-category-count">{blocks.length}</span>
|
||||||
|
<span className={`workflow-palette-chevron ${isExpanded ? 'expanded' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="workflow-palette-blocks">
|
||||||
|
{blocks.map((block) => (
|
||||||
|
<BlockCard
|
||||||
|
key={block.type}
|
||||||
|
block={block}
|
||||||
|
categoryColor={info.color}
|
||||||
|
onClick={() => onBlockClick(block.type)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Card Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface BlockCardProps {
|
||||||
|
block: BlockDefinition
|
||||||
|
categoryColor: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlockCard: React.FC<BlockCardProps> = ({ block, categoryColor, onClick }) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="workflow-palette-block"
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: isHovered ? categoryColor : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="workflow-palette-block-icon">{block.icon}</span>
|
||||||
|
<div className="workflow-palette-block-content">
|
||||||
|
<span className="workflow-palette-block-name">{block.name}</span>
|
||||||
|
<span className="workflow-palette-block-description">
|
||||||
|
{block.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="workflow-palette-block-ports">
|
||||||
|
<span className="workflow-palette-port-count" title="Inputs">
|
||||||
|
← {block.inputs.length}
|
||||||
|
</span>
|
||||||
|
<span className="workflow-palette-port-count" title="Outputs">
|
||||||
|
{block.outputs.length} →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Search Bar Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchBar: React.FC<SearchBarProps> = ({ value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="workflow-palette-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search blocks..."
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="workflow-palette-search-input"
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
className="workflow-palette-search-clear"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main Palette Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const WorkflowPalette: React.FC<WorkflowPaletteProps> = ({
|
||||||
|
editor,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<BlockCategory>>(
|
||||||
|
new Set(['trigger', 'action'])
|
||||||
|
)
|
||||||
|
|
||||||
|
const allBlocks = useMemo(() => getAllBlockDefinitions(), [])
|
||||||
|
|
||||||
|
const categories: BlockCategory[] = [
|
||||||
|
'trigger',
|
||||||
|
'action',
|
||||||
|
'condition',
|
||||||
|
'transformer',
|
||||||
|
'ai',
|
||||||
|
'output',
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredBlocksByCategory = useMemo(() => {
|
||||||
|
const result: Record<BlockCategory, BlockDefinition[]> = {
|
||||||
|
trigger: [],
|
||||||
|
action: [],
|
||||||
|
condition: [],
|
||||||
|
transformer: [],
|
||||||
|
ai: [],
|
||||||
|
output: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase()
|
||||||
|
|
||||||
|
for (const block of allBlocks) {
|
||||||
|
const matches =
|
||||||
|
!query ||
|
||||||
|
block.name.toLowerCase().includes(query) ||
|
||||||
|
block.description.toLowerCase().includes(query) ||
|
||||||
|
block.type.toLowerCase().includes(query)
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
result[block.category].push(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [allBlocks, searchQuery])
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBlockClick = useCallback(
|
||||||
|
(blockType: string) => {
|
||||||
|
// Set the block type for the tool
|
||||||
|
setWorkflowBlockType(blockType)
|
||||||
|
|
||||||
|
// Switch to the WorkflowBlock tool
|
||||||
|
editor.setCurrentTool('WorkflowBlock')
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workflow-palette">
|
||||||
|
<div className="workflow-palette-header">
|
||||||
|
<h3 className="workflow-palette-title">Workflow Blocks</h3>
|
||||||
|
<button className="workflow-palette-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||||
|
|
||||||
|
<div className="workflow-palette-content">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const blocks = filteredBlocksByCategory[category]
|
||||||
|
if (blocks.length === 0 && searchQuery) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CategorySection
|
||||||
|
key={category}
|
||||||
|
category={category}
|
||||||
|
blocks={blocks}
|
||||||
|
isExpanded={expandedCategories.has(category) || !!searchQuery}
|
||||||
|
onToggle={() => toggleCategory(category)}
|
||||||
|
onBlockClick={handleBlockClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workflow-palette-footer">
|
||||||
|
<div className="workflow-palette-hint">
|
||||||
|
Click a block to place it on the canvas
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowPalette
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
/**
|
||||||
|
* Workflow Palette Styles
|
||||||
|
*
|
||||||
|
* Styles for the workflow block palette sidebar component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Palette Container
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh;
|
||||||
|
background: white;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 4px 0 16px rgba(0, 0, 0, 0.08);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Header
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-close:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Search Bar
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette-search {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 32px 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search-input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search-clear:hover {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Content Area
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Category Section
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette-category {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-category-header:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-category-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-category-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-category-count {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-chevron {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-chevron.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Block Cards
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette-blocks {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block-description {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block-ports {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-port-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Footer
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Scrollbar Styling
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Dark Mode Support (optional)
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.workflow-palette {
|
||||||
|
background: #1f2937;
|
||||||
|
border-right-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-header {
|
||||||
|
background: #111827;
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-title {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-close {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-close:hover {
|
||||||
|
background: #374151;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search {
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search-input {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search-input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-category-header {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-category-header:hover {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block:hover {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block-name {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-block-description {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-footer {
|
||||||
|
background: #111827;
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Responsive Adjustments
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.workflow-palette {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,438 @@
|
||||||
|
/**
|
||||||
|
* Block Registry
|
||||||
|
*
|
||||||
|
* Defines all available workflow blocks with their ports and configuration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BlockDefinition, BlockCategory } from './types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Registry
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const blockRegistry = new Map<string, BlockDefinition>()
|
||||||
|
|
||||||
|
export function registerBlock(definition: BlockDefinition): void {
|
||||||
|
blockRegistry.set(definition.type, definition)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlockDefinition(type: string): BlockDefinition {
|
||||||
|
const def = blockRegistry.get(type)
|
||||||
|
if (!def) {
|
||||||
|
throw new Error(`Unknown block type: ${type}`)
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBlockDefinition(type: string): boolean {
|
||||||
|
return blockRegistry.has(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllBlockDefinitions(): BlockDefinition[] {
|
||||||
|
return Array.from(blockRegistry.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlocksByCategory(category: BlockCategory): BlockDefinition[] {
|
||||||
|
return getAllBlockDefinitions().filter(b => b.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Trigger Blocks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'trigger.manual',
|
||||||
|
name: 'Manual Trigger',
|
||||||
|
description: 'Start workflow manually with a button click',
|
||||||
|
icon: '▶️',
|
||||||
|
category: 'trigger',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'timestamp', name: 'Timestamp', type: 'number', produces: 'number' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'trigger.schedule',
|
||||||
|
name: 'Schedule Trigger',
|
||||||
|
description: 'Start workflow on a schedule',
|
||||||
|
icon: '⏰',
|
||||||
|
category: 'trigger',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'timestamp', name: 'Timestamp', type: 'number', produces: 'number' },
|
||||||
|
],
|
||||||
|
defaultConfig: { interval: 'daily', time: '09:00' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'trigger.webhook',
|
||||||
|
name: 'Webhook Trigger',
|
||||||
|
description: 'Start workflow from HTTP request',
|
||||||
|
icon: '🌐',
|
||||||
|
category: 'trigger',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'body', name: 'Body', type: 'object', produces: 'object' },
|
||||||
|
{ id: 'headers', name: 'Headers', type: 'object', produces: 'object' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Action Blocks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'action.http',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
description: 'Make an HTTP request to an API',
|
||||||
|
icon: '🔗',
|
||||||
|
category: 'action',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'url', name: 'URL', type: 'text', accepts: ['text'], required: true },
|
||||||
|
{ id: 'body', name: 'Body', type: 'any', accepts: ['text', 'object', 'any'] },
|
||||||
|
{ id: 'trigger', name: 'Trigger', type: 'any', accepts: ['any'] },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'response', name: 'Response', type: 'any', produces: 'any' },
|
||||||
|
{ id: 'status', name: 'Status', type: 'number', produces: 'number' },
|
||||||
|
],
|
||||||
|
defaultConfig: { method: 'GET' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'action.createShape',
|
||||||
|
name: 'Create Shape',
|
||||||
|
description: 'Create a new shape on the canvas',
|
||||||
|
icon: '📦',
|
||||||
|
category: 'action',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'content', name: 'Content', type: 'text', accepts: ['text', 'any'] },
|
||||||
|
{ id: 'position', name: 'Position', type: 'object', accepts: ['object'] },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'shapeId', name: 'Shape ID', type: 'text', produces: 'text' },
|
||||||
|
],
|
||||||
|
defaultConfig: { shapeType: 'text' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'action.delay',
|
||||||
|
name: 'Delay',
|
||||||
|
description: 'Wait for a specified duration',
|
||||||
|
icon: '⏳',
|
||||||
|
category: 'action',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'input', name: 'Input', type: 'any', accepts: ['any'] },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'passthrough', name: 'Output', type: 'any', produces: 'any' },
|
||||||
|
],
|
||||||
|
defaultConfig: { duration: 1000 },
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Condition Blocks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'condition.if',
|
||||||
|
name: 'If / Else',
|
||||||
|
description: 'Branch based on a condition',
|
||||||
|
icon: '🔀',
|
||||||
|
category: 'condition',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'condition', name: 'Condition', type: 'boolean', accepts: ['boolean', 'any'], required: true },
|
||||||
|
{ id: 'value', name: 'Value', type: 'any', accepts: ['any'] },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'true', name: 'True', type: 'any', produces: 'any' },
|
||||||
|
{ id: 'false', name: 'False', type: 'any', produces: 'any' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'condition.switch',
|
||||||
|
name: 'Switch',
|
||||||
|
description: 'Route based on value matching',
|
||||||
|
icon: '🔃',
|
||||||
|
category: 'condition',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'value', name: 'Value', type: 'any', accepts: ['any'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'match', name: 'Match', type: 'any', produces: 'any' },
|
||||||
|
{ id: 'default', name: 'Default', type: 'any', produces: 'any' },
|
||||||
|
],
|
||||||
|
defaultConfig: { cases: {} },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'condition.compare',
|
||||||
|
name: 'Compare',
|
||||||
|
description: 'Compare two values',
|
||||||
|
icon: '⚖️',
|
||||||
|
category: 'condition',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'a', name: 'A', type: 'any', accepts: ['any'], required: true },
|
||||||
|
{ id: 'b', name: 'B', type: 'any', accepts: ['any'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'result', name: 'Result', type: 'boolean', produces: 'boolean' },
|
||||||
|
],
|
||||||
|
defaultConfig: { operator: 'equals' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Transformer Blocks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'transformer.jsonParse',
|
||||||
|
name: 'JSON Parse',
|
||||||
|
description: 'Parse JSON text into object',
|
||||||
|
icon: '📋',
|
||||||
|
category: 'transformer',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'input', name: 'Input', type: 'text', accepts: ['text'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'output', name: 'Output', type: 'object', produces: 'object' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'transformer.jsonStringify',
|
||||||
|
name: 'JSON Stringify',
|
||||||
|
description: 'Convert object to JSON text',
|
||||||
|
icon: '📝',
|
||||||
|
category: 'transformer',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'input', name: 'Input', type: 'any', accepts: ['any'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'output', name: 'Output', type: 'text', produces: 'text' },
|
||||||
|
],
|
||||||
|
defaultConfig: { pretty: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'transformer.code',
|
||||||
|
name: 'JavaScript Code',
|
||||||
|
description: 'Run custom JavaScript code',
|
||||||
|
icon: '💻',
|
||||||
|
category: 'transformer',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'input', name: 'Input', type: 'any', accepts: ['any'] },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'output', name: 'Output', type: 'any', produces: 'any' },
|
||||||
|
],
|
||||||
|
defaultConfig: { code: 'return input' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'transformer.template',
|
||||||
|
name: 'Template',
|
||||||
|
description: 'Fill template with variables',
|
||||||
|
icon: '📄',
|
||||||
|
category: 'transformer',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'variables', name: 'Variables', type: 'object', accepts: ['object'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'output', name: 'Output', type: 'text', produces: 'text' },
|
||||||
|
],
|
||||||
|
defaultConfig: { template: 'Hello {{name}}!' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'transformer.getProperty',
|
||||||
|
name: 'Get Property',
|
||||||
|
description: 'Get a property from an object',
|
||||||
|
icon: '🔍',
|
||||||
|
category: 'transformer',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'object', name: 'Object', type: 'object', accepts: ['object'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'value', name: 'Value', type: 'any', produces: 'any' },
|
||||||
|
],
|
||||||
|
defaultConfig: { path: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'transformer.setProperty',
|
||||||
|
name: 'Set Property',
|
||||||
|
description: 'Set a property on an object',
|
||||||
|
icon: '✏️',
|
||||||
|
category: 'transformer',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'object', name: 'Object', type: 'object', accepts: ['object'], required: true },
|
||||||
|
{ id: 'value', name: 'Value', type: 'any', accepts: ['any'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'output', name: 'Output', type: 'object', produces: 'object' },
|
||||||
|
],
|
||||||
|
defaultConfig: { path: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'transformer.arrayMap',
|
||||||
|
name: 'Array Map',
|
||||||
|
description: 'Transform each array element',
|
||||||
|
icon: '🗺️',
|
||||||
|
category: 'transformer',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'array', name: 'Array', type: 'array', accepts: ['array'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'output', name: 'Output', type: 'array', produces: 'array' },
|
||||||
|
],
|
||||||
|
defaultConfig: { expression: 'item' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'transformer.arrayFilter',
|
||||||
|
name: 'Array Filter',
|
||||||
|
description: 'Filter array elements',
|
||||||
|
icon: '🔎',
|
||||||
|
category: 'transformer',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'array', name: 'Array', type: 'array', accepts: ['array'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'output', name: 'Output', type: 'array', produces: 'array' },
|
||||||
|
],
|
||||||
|
defaultConfig: { condition: 'true' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AI Blocks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'ai.llm',
|
||||||
|
name: 'LLM Prompt',
|
||||||
|
description: 'Send prompt to language model',
|
||||||
|
icon: '🤖',
|
||||||
|
category: 'ai',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'prompt', name: 'Prompt', type: 'text', accepts: ['text'], required: true },
|
||||||
|
{ id: 'context', name: 'Context', type: 'text', accepts: ['text', 'any'] },
|
||||||
|
{ id: 'trigger', name: 'Trigger', type: 'any', accepts: ['any'] },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'response', name: 'Response', type: 'text', produces: 'text' },
|
||||||
|
{ id: 'tokens', name: 'Tokens', type: 'number', produces: 'number' },
|
||||||
|
],
|
||||||
|
defaultConfig: { systemPrompt: '', model: 'default' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'ai.imageGen',
|
||||||
|
name: 'Image Generation',
|
||||||
|
description: 'Generate image from prompt',
|
||||||
|
icon: '🎨',
|
||||||
|
category: 'ai',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'prompt', name: 'Prompt', type: 'text', accepts: ['text'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'image', name: 'Image', type: 'image', produces: 'image' },
|
||||||
|
],
|
||||||
|
defaultConfig: { size: '512x512' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'ai.tts',
|
||||||
|
name: 'Text to Speech',
|
||||||
|
description: 'Convert text to audio',
|
||||||
|
icon: '🔊',
|
||||||
|
category: 'ai',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'text', name: 'Text', type: 'text', accepts: ['text'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'audio', name: 'Audio', type: 'file', produces: 'file' },
|
||||||
|
],
|
||||||
|
defaultConfig: { voice: 'default' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'ai.stt',
|
||||||
|
name: 'Speech to Text',
|
||||||
|
description: 'Convert audio to text',
|
||||||
|
icon: '🎤',
|
||||||
|
category: 'ai',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'audio', name: 'Audio', type: 'file', accepts: ['file'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'text', name: 'Text', type: 'text', produces: 'text' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Output Blocks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'output.display',
|
||||||
|
name: 'Display',
|
||||||
|
description: 'Display value on canvas',
|
||||||
|
icon: '📺',
|
||||||
|
category: 'output',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'value', name: 'Value', type: 'any', accepts: ['any'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'displayed', name: 'Displayed', type: 'text', produces: 'text' },
|
||||||
|
],
|
||||||
|
defaultConfig: { format: 'auto' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'output.log',
|
||||||
|
name: 'Log',
|
||||||
|
description: 'Log message to console',
|
||||||
|
icon: '📋',
|
||||||
|
category: 'output',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'message', name: 'Message', type: 'any', accepts: ['any'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'logged', name: 'Logged', type: 'boolean', produces: 'boolean' },
|
||||||
|
],
|
||||||
|
defaultConfig: { level: 'info' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'output.notify',
|
||||||
|
name: 'Notify',
|
||||||
|
description: 'Show notification',
|
||||||
|
icon: '🔔',
|
||||||
|
category: 'output',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'message', name: 'Message', type: 'text', accepts: ['text'], required: true },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'notified', name: 'Notified', type: 'boolean', produces: 'boolean' },
|
||||||
|
],
|
||||||
|
defaultConfig: { title: 'Notification' },
|
||||||
|
})
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'output.markdown',
|
||||||
|
name: 'Create Markdown',
|
||||||
|
description: 'Create markdown shape on canvas',
|
||||||
|
icon: '📝',
|
||||||
|
category: 'output',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'content', name: 'Content', type: 'text', accepts: ['text'], required: true },
|
||||||
|
{ id: 'position', name: 'Position', type: 'object', accepts: ['object'] },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: 'shapeId', name: 'Shape ID', type: 'text', produces: 'text' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,556 @@
|
||||||
|
/**
|
||||||
|
* Workflow Executor
|
||||||
|
*
|
||||||
|
* Handles manual execution of workflow blocks and complete workflows.
|
||||||
|
* Supports topological execution order, error handling, and state updates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Editor, TLShapeId } from 'tldraw'
|
||||||
|
import {
|
||||||
|
ExecutionContext,
|
||||||
|
BlockExecutionResult,
|
||||||
|
ExecutionState,
|
||||||
|
} from './types'
|
||||||
|
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
|
||||||
|
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
|
||||||
|
import {
|
||||||
|
getExecutionOrder,
|
||||||
|
getBlockInputBindings,
|
||||||
|
getBlockOutputBindings,
|
||||||
|
} from './portBindings'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Executors Registry
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type BlockExecutor = (
|
||||||
|
context: ExecutionContext,
|
||||||
|
inputs: Record<string, unknown>,
|
||||||
|
config: Record<string, unknown>
|
||||||
|
) => Promise<Record<string, unknown>>
|
||||||
|
|
||||||
|
const blockExecutors = new Map<string, BlockExecutor>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom executor for a block type
|
||||||
|
*/
|
||||||
|
export function registerBlockExecutor(
|
||||||
|
blockType: string,
|
||||||
|
executor: BlockExecutor
|
||||||
|
): void {
|
||||||
|
blockExecutors.set(blockType, executor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Built-in Block Executors
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Trigger: Manual
|
||||||
|
registerBlockExecutor('trigger.manual', async () => {
|
||||||
|
return { timestamp: Date.now() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger: Schedule
|
||||||
|
registerBlockExecutor('trigger.schedule', async () => {
|
||||||
|
return { timestamp: Date.now() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger: Webhook
|
||||||
|
registerBlockExecutor('trigger.webhook', async (_ctx, inputs) => {
|
||||||
|
return {
|
||||||
|
body: inputs.body || {},
|
||||||
|
headers: inputs.headers || {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Action: HTTP Request
|
||||||
|
registerBlockExecutor('action.http', async (_ctx, inputs, config) => {
|
||||||
|
const url = inputs.url as string
|
||||||
|
const method = (config.method as string) || 'GET'
|
||||||
|
const body = inputs.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: method !== 'GET' && body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => response.text())
|
||||||
|
return {
|
||||||
|
response: data,
|
||||||
|
status: response.status,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`HTTP request failed: ${error}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Action: Delay
|
||||||
|
registerBlockExecutor('action.delay', async (_ctx, inputs, config) => {
|
||||||
|
const duration = (config.duration as number) || 1000
|
||||||
|
await new Promise(resolve => setTimeout(resolve, duration))
|
||||||
|
return { passthrough: inputs.input }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Condition: If/Else
|
||||||
|
registerBlockExecutor('condition.if', async (_ctx, inputs) => {
|
||||||
|
const condition = Boolean(inputs.condition)
|
||||||
|
const value = inputs.value
|
||||||
|
|
||||||
|
if (condition) {
|
||||||
|
return { true: value, false: undefined }
|
||||||
|
} else {
|
||||||
|
return { true: undefined, false: value }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Condition: Compare
|
||||||
|
registerBlockExecutor('condition.compare', async (_ctx, inputs, config) => {
|
||||||
|
const a = inputs.a
|
||||||
|
const b = inputs.b
|
||||||
|
const operator = (config.operator as string) || 'equals'
|
||||||
|
|
||||||
|
let result = false
|
||||||
|
switch (operator) {
|
||||||
|
case 'equals':
|
||||||
|
result = a === b
|
||||||
|
break
|
||||||
|
case 'not_equals':
|
||||||
|
result = a !== b
|
||||||
|
break
|
||||||
|
case 'greater':
|
||||||
|
result = (a as number) > (b as number)
|
||||||
|
break
|
||||||
|
case 'less':
|
||||||
|
result = (a as number) < (b as number)
|
||||||
|
break
|
||||||
|
case 'greater_equal':
|
||||||
|
result = (a as number) >= (b as number)
|
||||||
|
break
|
||||||
|
case 'less_equal':
|
||||||
|
result = (a as number) <= (b as number)
|
||||||
|
break
|
||||||
|
case 'contains':
|
||||||
|
result = String(a).includes(String(b))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: JSON Parse
|
||||||
|
registerBlockExecutor('transformer.jsonParse', async (_ctx, inputs) => {
|
||||||
|
const input = inputs.input as string
|
||||||
|
try {
|
||||||
|
return { output: JSON.parse(input) }
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`JSON parse error: ${error}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: JSON Stringify
|
||||||
|
registerBlockExecutor('transformer.jsonStringify', async (_ctx, inputs, config) => {
|
||||||
|
const pretty = config.pretty as boolean
|
||||||
|
const output = pretty
|
||||||
|
? JSON.stringify(inputs.input, null, 2)
|
||||||
|
: JSON.stringify(inputs.input)
|
||||||
|
return { output }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: JavaScript Code
|
||||||
|
registerBlockExecutor('transformer.code', async (_ctx, inputs, config) => {
|
||||||
|
const code = config.code as string
|
||||||
|
const input = inputs.input
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a sandboxed function
|
||||||
|
const fn = new Function('input', `return (${code})`)
|
||||||
|
const output = fn(input)
|
||||||
|
return { output }
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Code execution error: ${error}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Template
|
||||||
|
registerBlockExecutor('transformer.template', async (_ctx, inputs, config) => {
|
||||||
|
const template = config.template as string
|
||||||
|
const variables = inputs.variables as Record<string, unknown>
|
||||||
|
|
||||||
|
let output = template
|
||||||
|
for (const [key, value] of Object.entries(variables || {})) {
|
||||||
|
output = output.replace(new RegExp(`{{${key}}}`, 'g'), String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { output }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Get Property
|
||||||
|
registerBlockExecutor('transformer.getProperty', async (_ctx, inputs, config) => {
|
||||||
|
const obj = inputs.object as Record<string, unknown>
|
||||||
|
const path = (config.path as string) || ''
|
||||||
|
|
||||||
|
const parts = path.split('.')
|
||||||
|
let value: unknown = obj
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (value == null || typeof value !== 'object') {
|
||||||
|
value = undefined
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value = (value as Record<string, unknown>)[part]
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Set Property
|
||||||
|
registerBlockExecutor('transformer.setProperty', async (_ctx, inputs, config) => {
|
||||||
|
const obj = { ...(inputs.object as Record<string, unknown>) }
|
||||||
|
const path = (config.path as string) || ''
|
||||||
|
const value = inputs.value
|
||||||
|
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: Record<string, unknown> = obj
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i]
|
||||||
|
if (!(part in current) || typeof current[part] !== 'object') {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
current = current[part] as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
current[parts[parts.length - 1]] = value
|
||||||
|
return { output: obj }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Array Map
|
||||||
|
registerBlockExecutor('transformer.arrayMap', async (_ctx, inputs, config) => {
|
||||||
|
const array = inputs.array as unknown[]
|
||||||
|
const expression = config.expression as string
|
||||||
|
|
||||||
|
const fn = new Function('item', 'index', `return ${expression}`)
|
||||||
|
const output = array.map((item, index) => fn(item, index))
|
||||||
|
|
||||||
|
return { output }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Array Filter
|
||||||
|
registerBlockExecutor('transformer.arrayFilter', async (_ctx, inputs, config) => {
|
||||||
|
const array = inputs.array as unknown[]
|
||||||
|
const condition = config.condition as string
|
||||||
|
|
||||||
|
const fn = new Function('item', 'index', `return ${condition}`)
|
||||||
|
const output = array.filter((item, index) => fn(item, index))
|
||||||
|
|
||||||
|
return { output }
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI: LLM Prompt (placeholder - integrate with actual LLM service)
|
||||||
|
registerBlockExecutor('ai.llm', async (_ctx, inputs, config) => {
|
||||||
|
const prompt = inputs.prompt as string
|
||||||
|
const context = inputs.context as string
|
||||||
|
const systemPrompt = config.systemPrompt as string
|
||||||
|
|
||||||
|
// Placeholder - would integrate with actual LLM API
|
||||||
|
console.log('[AI LLM] Prompt:', prompt)
|
||||||
|
console.log('[AI LLM] Context:', context)
|
||||||
|
console.log('[AI LLM] System:', systemPrompt)
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: `[LLM Response placeholder for: ${prompt}]`,
|
||||||
|
tokens: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI: Image Generation (placeholder)
|
||||||
|
registerBlockExecutor('ai.imageGen', async (_ctx, inputs, config) => {
|
||||||
|
const prompt = inputs.prompt as string
|
||||||
|
const size = config.size as string
|
||||||
|
|
||||||
|
console.log('[AI Image] Prompt:', prompt, 'Size:', size)
|
||||||
|
|
||||||
|
return {
|
||||||
|
image: `[Generated image placeholder for: ${prompt}]`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output: Display
|
||||||
|
registerBlockExecutor('output.display', async (_ctx, inputs, config) => {
|
||||||
|
const value = inputs.value
|
||||||
|
const format = config.format as string
|
||||||
|
|
||||||
|
let displayValue: string
|
||||||
|
if (format === 'json' || format === 'auto') {
|
||||||
|
displayValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||||
|
} else {
|
||||||
|
displayValue = String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Display]:', displayValue)
|
||||||
|
return { displayed: displayValue }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output: Log
|
||||||
|
registerBlockExecutor('output.log', async (_ctx, inputs, config) => {
|
||||||
|
const message = inputs.message
|
||||||
|
const level = (config.level as string) || 'info'
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
console.error('[Workflow Log]:', message)
|
||||||
|
break
|
||||||
|
case 'warn':
|
||||||
|
console.warn('[Workflow Log]:', message)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.log('[Workflow Log]:', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logged: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output: Notify
|
||||||
|
registerBlockExecutor('output.notify', async (_ctx, inputs, config) => {
|
||||||
|
const message = inputs.message as string
|
||||||
|
const title = (config.title as string) || 'Notification'
|
||||||
|
|
||||||
|
// Dispatch custom event for UI to handle
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('workflow:notification', {
|
||||||
|
detail: { title, message },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return { notified: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Execution
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single workflow block
|
||||||
|
*/
|
||||||
|
export async function executeBlock(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId,
|
||||||
|
inputs: Record<string, unknown> = {}
|
||||||
|
): Promise<BlockExecutionResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
const shape = editor.getShape(blockId) as IWorkflowBlock | undefined
|
||||||
|
if (!shape || shape.type !== 'WorkflowBlock') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
outputs: {},
|
||||||
|
error: 'Block not found',
|
||||||
|
executionTime: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { blockType, blockConfig } = shape.props
|
||||||
|
|
||||||
|
if (!hasBlockDefinition(blockType)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
outputs: {},
|
||||||
|
error: `Unknown block type: ${blockType}`,
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set running state
|
||||||
|
updateBlockState(editor, blockId, 'running')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executor = blockExecutors.get(blockType)
|
||||||
|
if (!executor) {
|
||||||
|
throw new Error(`No executor registered for block type: ${blockType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: ExecutionContext = {
|
||||||
|
editor,
|
||||||
|
blockId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputs = await executor(context, inputs, blockConfig)
|
||||||
|
|
||||||
|
// Update block with outputs and success state
|
||||||
|
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 instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
|
// Update block with error state
|
||||||
|
editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: blockId,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: {
|
||||||
|
executionState: 'error',
|
||||||
|
executionError: errorMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
outputs: {},
|
||||||
|
error: errorMessage,
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a block's 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Workflow Execution
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a complete workflow starting from trigger blocks
|
||||||
|
*/
|
||||||
|
export async function executeWorkflow(
|
||||||
|
editor: Editor,
|
||||||
|
startBlockId?: TLShapeId
|
||||||
|
): Promise<Map<TLShapeId, BlockExecutionResult>> {
|
||||||
|
const results = new Map<TLShapeId, BlockExecutionResult>()
|
||||||
|
const outputValues = new Map<TLShapeId, Record<string, unknown>>()
|
||||||
|
|
||||||
|
// Get execution order
|
||||||
|
const executionOrder = getExecutionOrder(editor, startBlockId)
|
||||||
|
|
||||||
|
if (executionOrder.length === 0) {
|
||||||
|
console.warn('No blocks to execute in workflow')
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Workflow] Executing ${executionOrder.length} blocks`)
|
||||||
|
|
||||||
|
for (const blockId of executionOrder) {
|
||||||
|
// Gather inputs from upstream blocks
|
||||||
|
const inputs = gatherBlockInputs(editor, blockId, outputValues)
|
||||||
|
|
||||||
|
// Execute the block
|
||||||
|
const result = await executeBlock(editor, blockId, inputs)
|
||||||
|
results.set(blockId, result)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
outputValues.set(blockId, result.outputs)
|
||||||
|
} else {
|
||||||
|
console.error(`[Workflow] Block ${blockId} failed:`, result.error)
|
||||||
|
// Optionally stop on first error
|
||||||
|
// break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Workflow] Execution complete')
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather input values for a block from its upstream connections
|
||||||
|
*/
|
||||||
|
function gatherBlockInputs(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId,
|
||||||
|
outputValues: Map<TLShapeId, Record<string, unknown>>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const inputs: Record<string, unknown> = {}
|
||||||
|
const bindings = getBlockInputBindings(editor, blockId)
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const sourceOutputs = outputValues.get(binding.fromShapeId)
|
||||||
|
if (sourceOutputs && binding.fromPortId in sourceOutputs) {
|
||||||
|
inputs[binding.toPortId] = sourceOutputs[binding.fromPortId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also include any static input values from the block itself
|
||||||
|
const shape = editor.getShape(blockId) as IWorkflowBlock | undefined
|
||||||
|
if (shape && shape.type === 'WorkflowBlock') {
|
||||||
|
for (const [key, value] of Object.entries(shape.props.inputValues)) {
|
||||||
|
if (!(key in inputs)) {
|
||||||
|
inputs[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Execution Event Listener Setup
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up listener for block execution events
|
||||||
|
*/
|
||||||
|
export function setupBlockExecutionListener(editor: Editor): () => void {
|
||||||
|
const handler = (event: CustomEvent<{ blockId: TLShapeId }>) => {
|
||||||
|
const { blockId } = event.detail
|
||||||
|
executeWorkflow(editor, blockId)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('workflow:execute-block', handler as EventListener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('workflow:execute-block', handler as EventListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all blocks to idle state
|
||||||
|
*/
|
||||||
|
export function resetWorkflowState(editor: Editor): void {
|
||||||
|
const blocks = editor
|
||||||
|
.getCurrentPageShapes()
|
||||||
|
.filter((s): s is IWorkflowBlock => s.type === 'WorkflowBlock')
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: block.id,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: {
|
||||||
|
executionState: 'idle',
|
||||||
|
executionError: undefined,
|
||||||
|
outputValues: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,464 @@
|
||||||
|
/**
|
||||||
|
* Port Binding Utilities
|
||||||
|
*
|
||||||
|
* Manages port-to-port connections between workflow blocks via tldraw arrows.
|
||||||
|
* Stores binding info in arrow metadata and provides utilities for querying.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Editor, TLShapeId, TLArrowShape, TLShape, TLArrowShapeArrowheadStyle, JsonObject } from 'tldraw'
|
||||||
|
import { PortBinding } from './types'
|
||||||
|
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Arrow Metadata Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface WorkflowArrowMeta extends JsonObject {
|
||||||
|
isWorkflowBinding?: boolean
|
||||||
|
fromPortId?: string
|
||||||
|
toPortId?: string
|
||||||
|
validated?: boolean
|
||||||
|
dataType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guard for arrow binding terminal
|
||||||
|
interface ArrowBindingTerminal {
|
||||||
|
type: 'binding'
|
||||||
|
boundShapeId: TLShapeId
|
||||||
|
normalizedAnchor: { x: number; y: number }
|
||||||
|
isExact: boolean
|
||||||
|
isPrecise: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArrowBinding(terminal: unknown): terminal is ArrowBindingTerminal {
|
||||||
|
return (
|
||||||
|
terminal !== null &&
|
||||||
|
typeof terminal === 'object' &&
|
||||||
|
'type' in terminal &&
|
||||||
|
(terminal as { type: string }).type === 'binding' &&
|
||||||
|
'boundShapeId' in terminal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Binding Extraction
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract port binding info 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 meta = arrow.meta as WorkflowArrowMeta
|
||||||
|
if (!meta?.isWorkflowBinding) return null
|
||||||
|
|
||||||
|
const startBinding = arrow.props.start
|
||||||
|
const endBinding = arrow.props.end
|
||||||
|
|
||||||
|
if (!isArrowBinding(startBinding) || !isArrowBinding(endBinding)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fromShapeId: startBinding.boundShapeId,
|
||||||
|
fromPortId: meta.fromPortId || 'output',
|
||||||
|
toShapeId: endBinding.boundShapeId,
|
||||||
|
toPortId: meta.toPortId || 'input',
|
||||||
|
arrowId: arrowId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all port bindings in the editor
|
||||||
|
*/
|
||||||
|
export function getAllBindings(editor: Editor): PortBinding[] {
|
||||||
|
const arrows = editor.getCurrentPageShapes().filter(
|
||||||
|
(s): s is TLArrowShape => s.type === 'arrow'
|
||||||
|
)
|
||||||
|
|
||||||
|
const bindings: PortBinding[] = []
|
||||||
|
for (const arrow of arrows) {
|
||||||
|
const binding = getPortBinding(editor, arrow.id)
|
||||||
|
if (binding) {
|
||||||
|
bindings.push(binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all arrows connected to workflow blocks (even without metadata)
|
||||||
|
*/
|
||||||
|
export function getWorkflowArrows(editor: Editor): TLArrowShape[] {
|
||||||
|
const arrows = editor.getCurrentPageShapes().filter(
|
||||||
|
(s): s is TLArrowShape => s.type === 'arrow'
|
||||||
|
)
|
||||||
|
|
||||||
|
return arrows.filter(arrow => {
|
||||||
|
const start = arrow.props.start
|
||||||
|
const end = arrow.props.end
|
||||||
|
|
||||||
|
if (!isArrowBinding(start) || !isArrowBinding(end)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const startShape = editor.getShape(start.boundShapeId)
|
||||||
|
const endShape = editor.getShape(end.boundShapeId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
startShape?.type === 'WorkflowBlock' ||
|
||||||
|
endShape?.type === 'WorkflowBlock'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block-specific Queries
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all input bindings for a workflow block
|
||||||
|
*/
|
||||||
|
export function getBlockInputBindings(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): PortBinding[] {
|
||||||
|
return getAllBindings(editor).filter(b => b.toShapeId === blockId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all output bindings for a workflow block
|
||||||
|
*/
|
||||||
|
export function getBlockOutputBindings(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): PortBinding[] {
|
||||||
|
return getAllBindings(editor).filter(b => b.fromShapeId === blockId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all connected block IDs for a given block
|
||||||
|
*/
|
||||||
|
export function getConnectedBlocks(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): { upstream: TLShapeId[]; downstream: TLShapeId[] } {
|
||||||
|
const bindings = getAllBindings(editor)
|
||||||
|
|
||||||
|
const upstream = bindings
|
||||||
|
.filter(b => b.toShapeId === blockId)
|
||||||
|
.map(b => b.fromShapeId)
|
||||||
|
|
||||||
|
const downstream = bindings
|
||||||
|
.filter(b => b.fromShapeId === blockId)
|
||||||
|
.map(b => b.toShapeId)
|
||||||
|
|
||||||
|
return { upstream, downstream }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the binding for a specific input port
|
||||||
|
*/
|
||||||
|
export function getInputPortBinding(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId,
|
||||||
|
portId: string
|
||||||
|
): PortBinding | null {
|
||||||
|
const bindings = getBlockInputBindings(editor, blockId)
|
||||||
|
return bindings.find(b => b.toPortId === portId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all bindings for a specific output port
|
||||||
|
*/
|
||||||
|
export function getOutputPortBindings(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId,
|
||||||
|
portId: string
|
||||||
|
): PortBinding[] {
|
||||||
|
return getBlockOutputBindings(editor, blockId).filter(
|
||||||
|
b => b.fromPortId === portId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Binding Creation & Updates
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an arrow as a workflow binding with port metadata
|
||||||
|
*/
|
||||||
|
export function setArrowBinding(
|
||||||
|
editor: Editor,
|
||||||
|
arrowId: TLShapeId,
|
||||||
|
fromPortId: string,
|
||||||
|
toPortId: string,
|
||||||
|
dataType?: string
|
||||||
|
): void {
|
||||||
|
editor.updateShape({
|
||||||
|
id: arrowId,
|
||||||
|
type: 'arrow',
|
||||||
|
meta: {
|
||||||
|
isWorkflowBinding: true,
|
||||||
|
fromPortId,
|
||||||
|
toPortId,
|
||||||
|
validated: true,
|
||||||
|
dataType,
|
||||||
|
} as WorkflowArrowMeta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear workflow binding metadata from an arrow
|
||||||
|
*/
|
||||||
|
export function clearArrowBinding(editor: Editor, arrowId: TLShapeId): void {
|
||||||
|
editor.updateShape({
|
||||||
|
id: arrowId,
|
||||||
|
type: 'arrow',
|
||||||
|
meta: {
|
||||||
|
isWorkflowBinding: false,
|
||||||
|
fromPortId: undefined,
|
||||||
|
toPortId: undefined,
|
||||||
|
validated: false,
|
||||||
|
dataType: undefined,
|
||||||
|
} as WorkflowArrowMeta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a binding (delete the arrow)
|
||||||
|
*/
|
||||||
|
export function removeBinding(editor: Editor, binding: PortBinding): void {
|
||||||
|
editor.deleteShape(binding.arrowId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Position Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the world position of a port on a workflow block
|
||||||
|
*/
|
||||||
|
export function getPortWorldPosition(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId,
|
||||||
|
portId: string,
|
||||||
|
direction: 'input' | 'output'
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const shape = editor.getShape(blockId) as IWorkflowBlock | undefined
|
||||||
|
if (!shape || shape.type !== 'WorkflowBlock') return null
|
||||||
|
|
||||||
|
// Get the shape's transform
|
||||||
|
const point = editor.getShapePageTransform(blockId)?.point()
|
||||||
|
if (!point) return null
|
||||||
|
|
||||||
|
// Import dynamically to avoid circular deps
|
||||||
|
const { getBlockDefinition, hasBlockDefinition } = require('./blockRegistry')
|
||||||
|
|
||||||
|
if (!hasBlockDefinition(shape.props.blockType)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = getBlockDefinition(shape.props.blockType)
|
||||||
|
const ports = direction === 'input' ? definition.inputs : definition.outputs
|
||||||
|
const portIndex = ports.findIndex((p: { id: string }) => p.id === portId)
|
||||||
|
|
||||||
|
if (portIndex === -1) return null
|
||||||
|
|
||||||
|
const PORT_SIZE = 12
|
||||||
|
const PORT_SPACING = 28
|
||||||
|
const HEADER_HEIGHT = 36
|
||||||
|
|
||||||
|
const x = direction === 'input' ? 0 : shape.props.w
|
||||||
|
const y = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: point.x + x,
|
||||||
|
y: point.y + y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Connection Validation Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a connection already exists between two ports
|
||||||
|
*/
|
||||||
|
export function connectionExists(
|
||||||
|
editor: Editor,
|
||||||
|
fromBlockId: TLShapeId,
|
||||||
|
fromPortId: string,
|
||||||
|
toBlockId: TLShapeId,
|
||||||
|
toPortId: string
|
||||||
|
): boolean {
|
||||||
|
const bindings = getAllBindings(editor)
|
||||||
|
return bindings.some(
|
||||||
|
b =>
|
||||||
|
b.fromShapeId === fromBlockId &&
|
||||||
|
b.fromPortId === fromPortId &&
|
||||||
|
b.toShapeId === toBlockId &&
|
||||||
|
b.toPortId === toPortId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an input port already has a connection
|
||||||
|
*/
|
||||||
|
export function inputPortHasConnection(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId,
|
||||||
|
portId: string
|
||||||
|
): boolean {
|
||||||
|
return getInputPortBinding(editor, blockId, portId) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of connected input port IDs for a block
|
||||||
|
*/
|
||||||
|
export function getConnectedInputPorts(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): string[] {
|
||||||
|
return getBlockInputBindings(editor, blockId).map(b => b.toPortId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of connected output port IDs for a block
|
||||||
|
*/
|
||||||
|
export function getConnectedOutputPorts(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): string[] {
|
||||||
|
const bindings = getBlockOutputBindings(editor, blockId)
|
||||||
|
return [...new Set(bindings.map(b => b.fromPortId))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Graph Traversal
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all workflow blocks in topological order (for execution)
|
||||||
|
*/
|
||||||
|
export function getExecutionOrder(
|
||||||
|
editor: Editor,
|
||||||
|
startBlockId?: TLShapeId
|
||||||
|
): TLShapeId[] {
|
||||||
|
const bindings = getAllBindings(editor)
|
||||||
|
const blocks = editor
|
||||||
|
.getCurrentPageShapes()
|
||||||
|
.filter((s): s is IWorkflowBlock => s.type === 'WorkflowBlock')
|
||||||
|
.map(s => s.id)
|
||||||
|
|
||||||
|
// Build adjacency list
|
||||||
|
const graph = new Map<TLShapeId, Set<TLShapeId>>()
|
||||||
|
const inDegree = new Map<TLShapeId, number>()
|
||||||
|
|
||||||
|
for (const blockId of blocks) {
|
||||||
|
graph.set(blockId, new Set())
|
||||||
|
inDegree.set(blockId, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
if (blocks.includes(binding.fromShapeId) && blocks.includes(binding.toShapeId)) {
|
||||||
|
graph.get(binding.fromShapeId)?.add(binding.toShapeId)
|
||||||
|
inDegree.set(
|
||||||
|
binding.toShapeId,
|
||||||
|
(inDegree.get(binding.toShapeId) || 0) + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn's algorithm for topological sort
|
||||||
|
const queue: TLShapeId[] = []
|
||||||
|
const result: TLShapeId[] = []
|
||||||
|
|
||||||
|
// Start from specified block or all roots
|
||||||
|
if (startBlockId && blocks.includes(startBlockId)) {
|
||||||
|
queue.push(startBlockId)
|
||||||
|
} else {
|
||||||
|
for (const [blockId, degree] of inDegree) {
|
||||||
|
if (degree === 0) {
|
||||||
|
queue.push(blockId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift()!
|
||||||
|
result.push(current)
|
||||||
|
|
||||||
|
for (const neighbor of graph.get(current) || []) {
|
||||||
|
const newDegree = (inDegree.get(neighbor) || 1) - 1
|
||||||
|
inDegree.set(neighbor, newDegree)
|
||||||
|
if (newDegree === 0) {
|
||||||
|
queue.push(neighbor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all blocks downstream from a given block
|
||||||
|
*/
|
||||||
|
export function getDownstreamBlocks(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): TLShapeId[] {
|
||||||
|
const visited = new Set<TLShapeId>()
|
||||||
|
const result: TLShapeId[] = []
|
||||||
|
|
||||||
|
function dfs(current: TLShapeId) {
|
||||||
|
if (visited.has(current)) return
|
||||||
|
visited.add(current)
|
||||||
|
|
||||||
|
const downstream = getBlockOutputBindings(editor, current).map(
|
||||||
|
b => b.toShapeId
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const next of downstream) {
|
||||||
|
result.push(next)
|
||||||
|
dfs(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dfs(blockId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all blocks upstream from a given block
|
||||||
|
*/
|
||||||
|
export function getUpstreamBlocks(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): TLShapeId[] {
|
||||||
|
const visited = new Set<TLShapeId>()
|
||||||
|
const result: TLShapeId[] = []
|
||||||
|
|
||||||
|
function dfs(current: TLShapeId) {
|
||||||
|
if (visited.has(current)) return
|
||||||
|
visited.add(current)
|
||||||
|
|
||||||
|
const upstream = getBlockInputBindings(editor, current).map(
|
||||||
|
b => b.fromShapeId
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const prev of upstream) {
|
||||||
|
result.push(prev)
|
||||||
|
dfs(prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dfs(blockId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,571 @@
|
||||||
|
/**
|
||||||
|
* Workflow Serialization
|
||||||
|
*
|
||||||
|
* Export and import workflows as JSON for sharing and backup.
|
||||||
|
* Compatible with a simplified Flowy-like format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Editor, TLShapeId, createShapeId } from 'tldraw'
|
||||||
|
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
|
||||||
|
import { WorkflowBlockProps, PortBinding } from './types'
|
||||||
|
import { getAllBindings, setArrowBinding } from './portBindings'
|
||||||
|
import { hasBlockDefinition } from './blockRegistry'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Serialization Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface SerializedBlock {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
blockType: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
config: Record<string, unknown>
|
||||||
|
inputValues: Record<string, unknown>
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedConnection {
|
||||||
|
id: string
|
||||||
|
fromBlock: string
|
||||||
|
fromPort: string
|
||||||
|
toBlock: string
|
||||||
|
toPort: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedWorkflow {
|
||||||
|
version: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
createdAt: string
|
||||||
|
blocks: SerializedBlock[]
|
||||||
|
connections: SerializedConnection[]
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Export Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export workflow blocks and connections from the editor
|
||||||
|
*/
|
||||||
|
export function exportWorkflow(
|
||||||
|
editor: Editor,
|
||||||
|
options: {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
selectedOnly?: boolean
|
||||||
|
} = {}
|
||||||
|
): SerializedWorkflow {
|
||||||
|
const { name = 'Untitled Workflow', description, selectedOnly = false } = options
|
||||||
|
|
||||||
|
// Get all workflow blocks
|
||||||
|
let blocks = editor
|
||||||
|
.getCurrentPageShapes()
|
||||||
|
.filter((s): s is IWorkflowBlock => s.type === 'WorkflowBlock')
|
||||||
|
|
||||||
|
if (selectedOnly) {
|
||||||
|
const selectedIds = new Set(editor.getSelectedShapeIds())
|
||||||
|
blocks = blocks.filter((b) => selectedIds.has(b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockIds = new Set(blocks.map((b) => b.id))
|
||||||
|
|
||||||
|
// Get all bindings between these blocks
|
||||||
|
const allBindings = getAllBindings(editor)
|
||||||
|
const relevantBindings = allBindings.filter(
|
||||||
|
(b) => blockIds.has(b.fromShapeId) && blockIds.has(b.toShapeId)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Serialize blocks
|
||||||
|
const serializedBlocks: SerializedBlock[] = blocks.map((block) => ({
|
||||||
|
id: block.id,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: block.props.blockType,
|
||||||
|
x: block.x,
|
||||||
|
y: block.y,
|
||||||
|
w: block.props.w,
|
||||||
|
h: block.props.h,
|
||||||
|
config: block.props.blockConfig,
|
||||||
|
inputValues: block.props.inputValues,
|
||||||
|
tags: block.props.tags,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Serialize connections
|
||||||
|
const serializedConnections: SerializedConnection[] = relevantBindings.map(
|
||||||
|
(binding) => ({
|
||||||
|
id: binding.arrowId,
|
||||||
|
fromBlock: binding.fromShapeId,
|
||||||
|
fromPort: binding.fromPortId,
|
||||||
|
toBlock: binding.toShapeId,
|
||||||
|
toPort: binding.toPortId,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: '1.0.0',
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
blocks: serializedBlocks,
|
||||||
|
connections: serializedConnections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export workflow as a JSON string
|
||||||
|
*/
|
||||||
|
export function exportWorkflowToJSON(
|
||||||
|
editor: Editor,
|
||||||
|
options: {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
selectedOnly?: boolean
|
||||||
|
pretty?: boolean
|
||||||
|
} = {}
|
||||||
|
): string {
|
||||||
|
const { pretty = true, ...workflowOptions } = options
|
||||||
|
const workflow = exportWorkflow(editor, workflowOptions)
|
||||||
|
return pretty ? JSON.stringify(workflow, null, 2) : JSON.stringify(workflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download workflow as a JSON file
|
||||||
|
*/
|
||||||
|
export function downloadWorkflow(
|
||||||
|
editor: Editor,
|
||||||
|
options: {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
selectedOnly?: boolean
|
||||||
|
} = {}
|
||||||
|
): void {
|
||||||
|
const json = exportWorkflowToJSON(editor, { ...options, pretty: true })
|
||||||
|
const filename = `${options.name || 'workflow'}-${Date.now()}.json`
|
||||||
|
|
||||||
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Import Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a workflow from serialized data
|
||||||
|
*/
|
||||||
|
export function importWorkflow(
|
||||||
|
editor: Editor,
|
||||||
|
workflow: SerializedWorkflow,
|
||||||
|
options: {
|
||||||
|
offset?: { x: number; y: number }
|
||||||
|
generateNewIds?: boolean
|
||||||
|
} = {}
|
||||||
|
): { blockIds: TLShapeId[]; arrowIds: TLShapeId[] } {
|
||||||
|
const { offset = { x: 0, y: 0 }, generateNewIds = true } = options
|
||||||
|
|
||||||
|
// Map old IDs to new IDs
|
||||||
|
const idMap = new Map<string, TLShapeId>()
|
||||||
|
|
||||||
|
// Validate blocks
|
||||||
|
const validBlocks = workflow.blocks.filter((block) => {
|
||||||
|
if (!hasBlockDefinition(block.blockType)) {
|
||||||
|
console.warn(`Unknown block type: ${block.blockType}, skipping`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create new IDs if needed
|
||||||
|
for (const block of validBlocks) {
|
||||||
|
const newId = generateNewIds
|
||||||
|
? createShapeId()
|
||||||
|
: (block.id as TLShapeId)
|
||||||
|
idMap.set(block.id, newId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bounding box for centering
|
||||||
|
let minX = Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
for (const block of validBlocks) {
|
||||||
|
minX = Math.min(minX, block.x)
|
||||||
|
minY = Math.min(minY, block.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blocks
|
||||||
|
const blockIds: TLShapeId[] = []
|
||||||
|
for (const block of validBlocks) {
|
||||||
|
const newId = idMap.get(block.id)!
|
||||||
|
blockIds.push(newId)
|
||||||
|
|
||||||
|
editor.createShape<IWorkflowBlock>({
|
||||||
|
id: newId,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: block.x - minX + offset.x,
|
||||||
|
y: block.y - minY + offset.y,
|
||||||
|
props: {
|
||||||
|
w: block.w,
|
||||||
|
h: block.h,
|
||||||
|
blockType: block.blockType,
|
||||||
|
blockConfig: block.config,
|
||||||
|
inputValues: block.inputValues,
|
||||||
|
outputValues: {},
|
||||||
|
executionState: 'idle',
|
||||||
|
tags: block.tags || ['workflow'],
|
||||||
|
pinnedToView: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create connections (arrows)
|
||||||
|
const arrowIds: TLShapeId[] = []
|
||||||
|
for (const conn of workflow.connections) {
|
||||||
|
const fromId = idMap.get(conn.fromBlock)
|
||||||
|
const toId = idMap.get(conn.toBlock)
|
||||||
|
|
||||||
|
if (!fromId || !toId) {
|
||||||
|
console.warn(`Skipping connection: missing block reference`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrowId = generateNewIds
|
||||||
|
? createShapeId()
|
||||||
|
: (conn.id as TLShapeId)
|
||||||
|
arrowIds.push(arrowId)
|
||||||
|
|
||||||
|
// Create arrow between blocks
|
||||||
|
editor.createShape({
|
||||||
|
id: arrowId,
|
||||||
|
type: 'arrow',
|
||||||
|
props: {
|
||||||
|
start: {
|
||||||
|
type: 'binding',
|
||||||
|
boundShapeId: fromId,
|
||||||
|
normalizedAnchor: { x: 1, y: 0.5 },
|
||||||
|
isExact: false,
|
||||||
|
isPrecise: false,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
type: 'binding',
|
||||||
|
boundShapeId: toId,
|
||||||
|
normalizedAnchor: { x: 0, y: 0.5 },
|
||||||
|
isExact: false,
|
||||||
|
isPrecise: false,
|
||||||
|
},
|
||||||
|
text: `flow{ ${conn.fromPort} -> ${conn.toPort} }`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set arrow binding metadata
|
||||||
|
setArrowBinding(editor, arrowId, conn.fromPort, conn.toPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select imported shapes
|
||||||
|
editor.setSelectedShapes([...blockIds, ...arrowIds])
|
||||||
|
|
||||||
|
return { blockIds, arrowIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import workflow from JSON string
|
||||||
|
*/
|
||||||
|
export function importWorkflowFromJSON(
|
||||||
|
editor: Editor,
|
||||||
|
json: string,
|
||||||
|
options: {
|
||||||
|
offset?: { x: number; y: number }
|
||||||
|
generateNewIds?: boolean
|
||||||
|
} = {}
|
||||||
|
): { blockIds: TLShapeId[]; arrowIds: TLShapeId[] } | null {
|
||||||
|
try {
|
||||||
|
const workflow = JSON.parse(json) as SerializedWorkflow
|
||||||
|
return importWorkflow(editor, workflow, options)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse workflow JSON:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import workflow from File object
|
||||||
|
*/
|
||||||
|
export async function importWorkflowFromFile(
|
||||||
|
editor: Editor,
|
||||||
|
file: File,
|
||||||
|
options: {
|
||||||
|
offset?: { x: number; y: number }
|
||||||
|
generateNewIds?: boolean
|
||||||
|
} = {}
|
||||||
|
): Promise<{ blockIds: TLShapeId[]; arrowIds: TLShapeId[] } | null> {
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
return importWorkflowFromJSON(editor, text, options)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read workflow file:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Template Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a basic API workflow template
|
||||||
|
*/
|
||||||
|
export function getApiWorkflowTemplate(): SerializedWorkflow {
|
||||||
|
return {
|
||||||
|
version: '1.0.0',
|
||||||
|
name: 'API Request Template',
|
||||||
|
description: 'Fetch data from an API and display the result',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'trigger.manual',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 150,
|
||||||
|
config: {},
|
||||||
|
inputValues: {},
|
||||||
|
tags: ['workflow', 'trigger'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'http-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'action.http',
|
||||||
|
x: 400,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
config: { method: 'GET' },
|
||||||
|
inputValues: { url: 'https://api.example.com/data' },
|
||||||
|
tags: ['workflow', 'action'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'display-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'output.display',
|
||||||
|
x: 700,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 150,
|
||||||
|
config: { format: 'json' },
|
||||||
|
inputValues: {},
|
||||||
|
tags: ['workflow', 'output'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'conn-1',
|
||||||
|
fromBlock: 'trigger-1',
|
||||||
|
fromPort: 'timestamp',
|
||||||
|
toBlock: 'http-1',
|
||||||
|
toPort: 'trigger',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-2',
|
||||||
|
fromBlock: 'http-1',
|
||||||
|
fromPort: 'response',
|
||||||
|
toBlock: 'display-1',
|
||||||
|
toPort: 'value',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an LLM chain workflow template
|
||||||
|
*/
|
||||||
|
export function getLLMChainTemplate(): SerializedWorkflow {
|
||||||
|
return {
|
||||||
|
version: '1.0.0',
|
||||||
|
name: 'LLM Chain Template',
|
||||||
|
description: 'Chain multiple LLM prompts together',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'trigger.manual',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 150,
|
||||||
|
config: {},
|
||||||
|
inputValues: {},
|
||||||
|
tags: ['workflow', 'trigger'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'llm-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'ai.llm',
|
||||||
|
x: 400,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 200,
|
||||||
|
config: { systemPrompt: 'You are a helpful assistant.' },
|
||||||
|
inputValues: { prompt: 'Summarize the following topic:' },
|
||||||
|
tags: ['workflow', 'ai'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'llm-2',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'ai.llm',
|
||||||
|
x: 700,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 200,
|
||||||
|
config: { systemPrompt: 'You expand on summaries with examples.' },
|
||||||
|
inputValues: {},
|
||||||
|
tags: ['workflow', 'ai'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'display-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'output.display',
|
||||||
|
x: 1000,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 150,
|
||||||
|
config: { format: 'auto' },
|
||||||
|
inputValues: {},
|
||||||
|
tags: ['workflow', 'output'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'conn-1',
|
||||||
|
fromBlock: 'trigger-1',
|
||||||
|
fromPort: 'timestamp',
|
||||||
|
toBlock: 'llm-1',
|
||||||
|
toPort: 'trigger',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-2',
|
||||||
|
fromBlock: 'llm-1',
|
||||||
|
fromPort: 'response',
|
||||||
|
toBlock: 'llm-2',
|
||||||
|
toPort: 'prompt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-3',
|
||||||
|
fromBlock: 'llm-2',
|
||||||
|
fromPort: 'response',
|
||||||
|
toBlock: 'display-1',
|
||||||
|
toPort: 'value',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a conditional branch workflow template
|
||||||
|
*/
|
||||||
|
export function getConditionalTemplate(): SerializedWorkflow {
|
||||||
|
return {
|
||||||
|
version: '1.0.0',
|
||||||
|
name: 'Conditional Branch Template',
|
||||||
|
description: 'Route data based on conditions',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'trigger.manual',
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
w: 220,
|
||||||
|
h: 150,
|
||||||
|
config: {},
|
||||||
|
inputValues: {},
|
||||||
|
tags: ['workflow', 'trigger'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compare-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'condition.compare',
|
||||||
|
x: 400,
|
||||||
|
y: 200,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
config: { operator: 'greater' },
|
||||||
|
inputValues: { a: 10, b: 5 },
|
||||||
|
tags: ['workflow', 'condition'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'if-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'condition.if',
|
||||||
|
x: 700,
|
||||||
|
y: 200,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
config: {},
|
||||||
|
inputValues: { value: 'Result data' },
|
||||||
|
tags: ['workflow', 'condition'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'display-true',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'output.display',
|
||||||
|
x: 1000,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 150,
|
||||||
|
config: { format: 'auto' },
|
||||||
|
inputValues: {},
|
||||||
|
tags: ['workflow', 'output'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'display-false',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
blockType: 'output.display',
|
||||||
|
x: 1000,
|
||||||
|
y: 300,
|
||||||
|
w: 220,
|
||||||
|
h: 150,
|
||||||
|
config: { format: 'auto' },
|
||||||
|
inputValues: {},
|
||||||
|
tags: ['workflow', 'output'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'conn-1',
|
||||||
|
fromBlock: 'compare-1',
|
||||||
|
fromPort: 'result',
|
||||||
|
toBlock: 'if-1',
|
||||||
|
toPort: 'condition',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-2',
|
||||||
|
fromBlock: 'if-1',
|
||||||
|
fromPort: 'true',
|
||||||
|
toBlock: 'display-true',
|
||||||
|
toPort: 'value',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-3',
|
||||||
|
fromBlock: 'if-1',
|
||||||
|
fromPort: 'false',
|
||||||
|
toBlock: 'display-false',
|
||||||
|
toPort: 'value',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
/**
|
||||||
|
* Workflow Type Definitions
|
||||||
|
*
|
||||||
|
* Core types for the Flowy-like workflow builder system.
|
||||||
|
* Supports typed ports, block definitions, and execution context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TLShapeId } from 'tldraw'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Data Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type PortDataType =
|
||||||
|
| 'text'
|
||||||
|
| 'number'
|
||||||
|
| 'boolean'
|
||||||
|
| 'object'
|
||||||
|
| 'array'
|
||||||
|
| 'any'
|
||||||
|
| 'file'
|
||||||
|
| 'image'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a source type is compatible with target accepted types
|
||||||
|
*/
|
||||||
|
export function isTypeCompatible(
|
||||||
|
sourceType: PortDataType,
|
||||||
|
targetAccepts: PortDataType[]
|
||||||
|
): boolean {
|
||||||
|
if (targetAccepts.includes('any')) return true
|
||||||
|
if (sourceType === 'any') return true
|
||||||
|
return targetAccepts.includes(sourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display color for port type
|
||||||
|
*/
|
||||||
|
export function getPortTypeColor(type: PortDataType): string {
|
||||||
|
const colors: Record<PortDataType, string> = {
|
||||||
|
text: '#3b82f6', // blue
|
||||||
|
number: '#10b981', // green
|
||||||
|
boolean: '#f59e0b', // amber
|
||||||
|
object: '#8b5cf6', // purple
|
||||||
|
array: '#06b6d4', // cyan
|
||||||
|
any: '#6b7280', // gray
|
||||||
|
file: '#ec4899', // pink
|
||||||
|
image: '#f97316', // orange
|
||||||
|
}
|
||||||
|
return colors[type] || colors.any
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Definitions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface InputPort {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: PortDataType
|
||||||
|
accepts: PortDataType[]
|
||||||
|
required?: boolean
|
||||||
|
defaultValue?: unknown
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutputPort {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: PortDataType
|
||||||
|
produces: PortDataType
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Categories
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type BlockCategory =
|
||||||
|
| 'trigger'
|
||||||
|
| 'action'
|
||||||
|
| 'condition'
|
||||||
|
| 'transformer'
|
||||||
|
| 'ai'
|
||||||
|
| 'output'
|
||||||
|
|
||||||
|
export const CATEGORY_INFO: Record<BlockCategory, { label: string; color: string; icon: string }> = {
|
||||||
|
trigger: { label: 'Triggers', color: '#ef4444', icon: '⚡' },
|
||||||
|
action: { label: 'Actions', color: '#3b82f6', icon: '🔧' },
|
||||||
|
condition: { label: 'Conditions', color: '#f59e0b', icon: '🔀' },
|
||||||
|
transformer: { label: 'Transformers', color: '#10b981', icon: '🔄' },
|
||||||
|
ai: { label: 'AI', color: '#8b5cf6', icon: '🤖' },
|
||||||
|
output: { label: 'Outputs', color: '#06b6d4', icon: '📤' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Definition
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface BlockDefinition {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
category: BlockCategory
|
||||||
|
inputs: InputPort[]
|
||||||
|
outputs: OutputPort[]
|
||||||
|
defaultConfig?: Record<string, unknown>
|
||||||
|
configSchema?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Workflow Block Props
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface WorkflowBlockProps {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
blockType: string
|
||||||
|
blockConfig: Record<string, unknown>
|
||||||
|
inputValues: Record<string, unknown>
|
||||||
|
outputValues: Record<string, unknown>
|
||||||
|
executionState: ExecutionState
|
||||||
|
executionError?: string
|
||||||
|
tags: string[]
|
||||||
|
pinnedToView: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecutionState = 'idle' | 'running' | 'success' | 'error'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Binding (Arrow Metadata)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface PortBinding {
|
||||||
|
fromShapeId: TLShapeId
|
||||||
|
fromPortId: string
|
||||||
|
toShapeId: TLShapeId
|
||||||
|
toPortId: string
|
||||||
|
arrowId: TLShapeId
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Execution Context
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ExecutionContext {
|
||||||
|
editor: unknown // Editor type
|
||||||
|
blockId: TLShapeId
|
||||||
|
timestamp: number
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockExecutionResult {
|
||||||
|
success: boolean
|
||||||
|
outputs: Record<string, unknown>
|
||||||
|
error?: string
|
||||||
|
executionTime: number
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,417 @@
|
||||||
|
/**
|
||||||
|
* Port Validation
|
||||||
|
*
|
||||||
|
* Handles type compatibility checking between ports and validates
|
||||||
|
* workflow connections to prevent invalid data flow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
PortDataType,
|
||||||
|
InputPort,
|
||||||
|
OutputPort,
|
||||||
|
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
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function canConnect(
|
||||||
|
outputPort: OutputPort,
|
||||||
|
inputPort: InputPort
|
||||||
|
): boolean {
|
||||||
|
return isTypeCompatible(outputPort.produces, inputPort.accepts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canConnectType(
|
||||||
|
outputType: PortDataType,
|
||||||
|
inputPort: InputPort
|
||||||
|
): boolean {
|
||||||
|
return isTypeCompatible(outputType, inputPort.accepts)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function validateConnection(
|
||||||
|
sourceBlockType: string,
|
||||||
|
sourcePortId: string,
|
||||||
|
targetBlockType: string,
|
||||||
|
targetPortId: string
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: ValidationError[] = []
|
||||||
|
const warnings: ValidationWarning[] = []
|
||||||
|
|
||||||
|
if (!hasBlockDefinition(sourceBlockType)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'unknown_block',
|
||||||
|
message: `Unknown source block type: ${sourceBlockType}`,
|
||||||
|
details: { blockType: sourceBlockType },
|
||||||
|
})
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
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 (!definition.configSchema) {
|
||||||
|
return { valid: true, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
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[] }
|
||||||
|
|
||||||
|
if (prop.required && !(key in config)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'missing_required',
|
||||||
|
message: `Missing required configuration: ${key}`,
|
||||||
|
details: { key },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function detectCycles(
|
||||||
|
connections: PortBinding[]
|
||||||
|
): { hasCycle: boolean; cycleNodes?: string[] } {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
const cycleStart = cyclePath.indexOf(cyclePath[cyclePath.length - 1])
|
||||||
|
return {
|
||||||
|
hasCycle: true,
|
||||||
|
cycleNodes: cyclePath.slice(cycleStart),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasCycle: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWorkflow(
|
||||||
|
blocks: Array<{ id: string; blockType: string; config: Record<string, unknown> }>,
|
||||||
|
connections: PortBinding[]
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: ValidationError[] = []
|
||||||
|
const warnings: ValidationWarning[] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleResult = detectCycles(connections)
|
||||||
|
if (cycleResult.hasCycle) {
|
||||||
|
errors.push({
|
||||||
|
type: 'cycle_detected',
|
||||||
|
message: `Cycle detected in workflow: ${cycleResult.cycleNodes?.join(' -> ')}`,
|
||||||
|
details: { cycleNodes: cycleResult.cycleNodes },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`)) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
/**
|
||||||
|
* WorkflowPropagator
|
||||||
|
*
|
||||||
|
* Real-time data propagation for workflow blocks.
|
||||||
|
* Automatically executes downstream blocks when source block outputs change.
|
||||||
|
* Uses 'flow' prefix syntax: flow{ ... } in arrow text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Editor, TLArrowShape, TLShape, TLShapeId } from 'tldraw'
|
||||||
|
import { getEdge } from '@/propagators/tlgraph'
|
||||||
|
import { isShapeOfType } from '@/propagators/utils'
|
||||||
|
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
|
||||||
|
import { getBlockDefinition, hasBlockDefinition } from '@/lib/workflow/blockRegistry'
|
||||||
|
import { executeBlock } from '@/lib/workflow/executor'
|
||||||
|
import {
|
||||||
|
setArrowBinding,
|
||||||
|
getBlockInputBindings,
|
||||||
|
getConnectedInputPorts,
|
||||||
|
} from '@/lib/workflow/portBindings'
|
||||||
|
import { validateRequiredInputs } from '@/lib/workflow/validation'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Propagator Registration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the workflow propagator with the editor
|
||||||
|
*/
|
||||||
|
export function registerWorkflowPropagator(editor: Editor): () => void {
|
||||||
|
const propagator = new WorkflowPropagator(editor)
|
||||||
|
const cleanup: (() => void)[] = []
|
||||||
|
|
||||||
|
// Initial scan for existing workflow arrows
|
||||||
|
for (const shape of editor.getCurrentPageShapes()) {
|
||||||
|
if (isShapeOfType<TLArrowShape>(shape, 'arrow')) {
|
||||||
|
propagator.onArrowChange(shape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register shape change handler
|
||||||
|
const shapeHandler = editor.sideEffects.registerAfterChangeHandler<'shape'>(
|
||||||
|
'shape',
|
||||||
|
(prev, next) => {
|
||||||
|
if (isShapeOfType<TLArrowShape>(next, 'arrow')) {
|
||||||
|
propagator.onArrowChange(next)
|
||||||
|
} else if (isWorkflowBlock(next)) {
|
||||||
|
propagator.onBlockChange(prev as IWorkflowBlock, next as IWorkflowBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cleanup.push(shapeHandler)
|
||||||
|
|
||||||
|
// Register binding create/delete handlers
|
||||||
|
const bindingCreateHandler = editor.sideEffects.registerAfterCreateHandler<'binding'>(
|
||||||
|
'binding',
|
||||||
|
(binding) => {
|
||||||
|
if (binding.type !== 'arrow') return
|
||||||
|
const arrow = editor.getShape(binding.fromId)
|
||||||
|
if (arrow && isShapeOfType<TLArrowShape>(arrow, 'arrow')) {
|
||||||
|
propagator.onArrowChange(arrow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cleanup.push(bindingCreateHandler)
|
||||||
|
|
||||||
|
const bindingDeleteHandler = editor.sideEffects.registerAfterDeleteHandler<'binding'>(
|
||||||
|
'binding',
|
||||||
|
(binding) => {
|
||||||
|
if (binding.type !== 'arrow') return
|
||||||
|
propagator.removeArrow(binding.fromId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cleanup.push(bindingDeleteHandler)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanup.forEach((fn) => fn())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function isWorkflowBlock(shape: TLShape): shape is IWorkflowBlock {
|
||||||
|
return shape.type === 'WorkflowBlock'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkflowArrow(arrow: TLArrowShape): boolean {
|
||||||
|
// Check if arrow text starts with 'flow{' or just connects two workflow blocks
|
||||||
|
const text = arrow.props.text.trim()
|
||||||
|
return text.startsWith('flow{') || text.startsWith('flow (')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFlowSyntax(text: string): { fromPort?: string; toPort?: string } | null {
|
||||||
|
// Parse flow{ fromPort -> toPort } syntax
|
||||||
|
const match = text.match(/^flow\s*\{\s*(\w+)?\s*(?:->|→)?\s*(\w+)?\s*\}$/i)
|
||||||
|
if (!match) {
|
||||||
|
// Try simple format: flow{}
|
||||||
|
if (/^flow\s*\{\s*\}$/.test(text)) {
|
||||||
|
return { fromPort: undefined, toPort: undefined }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fromPort: match[1] || undefined,
|
||||||
|
toPort: match[2] || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// WorkflowPropagator Class
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
class WorkflowPropagator {
|
||||||
|
private editor: Editor
|
||||||
|
private workflowArrows: Map<TLShapeId, { fromBlock: TLShapeId; toBlock: TLShapeId }> = new Map()
|
||||||
|
private pendingExecutions: Set<TLShapeId> = new Set()
|
||||||
|
private executionDebounce: Map<TLShapeId, NodeJS.Timeout> = new Map()
|
||||||
|
|
||||||
|
constructor(editor: Editor) {
|
||||||
|
this.editor = editor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when an arrow changes - check if it's a workflow connection
|
||||||
|
*/
|
||||||
|
onArrowChange(arrow: TLArrowShape): void {
|
||||||
|
const edge = getEdge(arrow, this.editor)
|
||||||
|
if (!edge) {
|
||||||
|
this.removeArrow(arrow.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromShape = this.editor.getShape(edge.from)
|
||||||
|
const toShape = this.editor.getShape(edge.to)
|
||||||
|
|
||||||
|
// Only track arrows between workflow blocks
|
||||||
|
if (!fromShape || !toShape) {
|
||||||
|
this.removeArrow(arrow.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWorkflowBlock(fromShape) || !isWorkflowBlock(toShape)) {
|
||||||
|
this.removeArrow(arrow.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse flow syntax if present, or use default ports
|
||||||
|
const text = arrow.props.text.trim()
|
||||||
|
const parsed = parseFlowSyntax(text)
|
||||||
|
|
||||||
|
// Determine port IDs
|
||||||
|
let fromPortId = 'output'
|
||||||
|
let toPortId = 'input'
|
||||||
|
|
||||||
|
if (parsed) {
|
||||||
|
if (parsed.fromPort) fromPortId = parsed.fromPort
|
||||||
|
if (parsed.toPort) toPortId = parsed.toPort
|
||||||
|
} else if (!text || !text.startsWith('flow')) {
|
||||||
|
// For arrows without explicit flow syntax between workflow blocks,
|
||||||
|
// try to infer the first available ports
|
||||||
|
const fromDef = hasBlockDefinition(fromShape.props.blockType)
|
||||||
|
? getBlockDefinition(fromShape.props.blockType)
|
||||||
|
: null
|
||||||
|
const toDef = hasBlockDefinition(toShape.props.blockType)
|
||||||
|
? getBlockDefinition(toShape.props.blockType)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (fromDef?.outputs.length) {
|
||||||
|
fromPortId = fromDef.outputs[0].id
|
||||||
|
}
|
||||||
|
if (toDef?.inputs.length) {
|
||||||
|
toPortId = toDef.inputs[0].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update arrow metadata
|
||||||
|
setArrowBinding(this.editor, arrow.id, fromPortId, toPortId)
|
||||||
|
|
||||||
|
// Track this arrow
|
||||||
|
this.workflowArrows.set(arrow.id, {
|
||||||
|
fromBlock: edge.from,
|
||||||
|
toBlock: edge.to,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a workflow block changes
|
||||||
|
*/
|
||||||
|
onBlockChange(prev: IWorkflowBlock, next: IWorkflowBlock): void {
|
||||||
|
// Check if outputs changed
|
||||||
|
const prevOutputs = prev.props.outputValues
|
||||||
|
const nextOutputs = next.props.outputValues
|
||||||
|
|
||||||
|
const outputsChanged =
|
||||||
|
JSON.stringify(prevOutputs) !== JSON.stringify(nextOutputs)
|
||||||
|
|
||||||
|
// Check if execution state changed to success
|
||||||
|
const justSucceeded =
|
||||||
|
prev.props.executionState !== 'success' &&
|
||||||
|
next.props.executionState === 'success'
|
||||||
|
|
||||||
|
if (outputsChanged || justSucceeded) {
|
||||||
|
this.propagateFromBlock(next.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove tracking for an arrow
|
||||||
|
*/
|
||||||
|
removeArrow(arrowId: TLShapeId): void {
|
||||||
|
this.workflowArrows.delete(arrowId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagate data from a block to all downstream connections
|
||||||
|
*/
|
||||||
|
private propagateFromBlock(blockId: TLShapeId): void {
|
||||||
|
const block = this.editor.getShape(blockId) as IWorkflowBlock | undefined
|
||||||
|
if (!block || block.type !== 'WorkflowBlock') return
|
||||||
|
|
||||||
|
// Find all arrows originating from this block
|
||||||
|
const downstreamBlocks = new Set<TLShapeId>()
|
||||||
|
|
||||||
|
for (const [, connection] of this.workflowArrows) {
|
||||||
|
if (connection.fromBlock === blockId) {
|
||||||
|
downstreamBlocks.add(connection.toBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule execution for each downstream block
|
||||||
|
for (const targetBlockId of downstreamBlocks) {
|
||||||
|
this.scheduleExecution(targetBlockId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule block execution with debouncing
|
||||||
|
*/
|
||||||
|
private scheduleExecution(blockId: TLShapeId): void {
|
||||||
|
// Cancel any pending execution for this block
|
||||||
|
const existing = this.executionDebounce.get(blockId)
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new execution
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.executeBlockIfReady(blockId)
|
||||||
|
this.executionDebounce.delete(blockId)
|
||||||
|
}, 50) // 50ms debounce
|
||||||
|
|
||||||
|
this.executionDebounce.set(blockId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a block if all required inputs are satisfied
|
||||||
|
*/
|
||||||
|
private async executeBlockIfReady(blockId: TLShapeId): Promise<void> {
|
||||||
|
if (this.pendingExecutions.has(blockId)) return
|
||||||
|
this.pendingExecutions.add(blockId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const block = this.editor.getShape(blockId) as IWorkflowBlock | undefined
|
||||||
|
if (!block || block.type !== 'WorkflowBlock') return
|
||||||
|
|
||||||
|
const { blockType, inputValues } = block.props
|
||||||
|
|
||||||
|
if (!hasBlockDefinition(blockType)) return
|
||||||
|
|
||||||
|
// Check if all required inputs are satisfied
|
||||||
|
const connectedInputs = getConnectedInputPorts(this.editor, blockId)
|
||||||
|
const validation = validateRequiredInputs(
|
||||||
|
blockType,
|
||||||
|
inputValues,
|
||||||
|
connectedInputs
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gather input values from upstream blocks
|
||||||
|
const inputs = this.gatherInputs(blockId)
|
||||||
|
|
||||||
|
// If validation passes (or has only warnings), execute
|
||||||
|
if (validation.valid || validation.errors.length === 0) {
|
||||||
|
await executeBlock(this.editor, blockId, inputs)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.pendingExecutions.delete(blockId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather input values from upstream connected blocks
|
||||||
|
*/
|
||||||
|
private gatherInputs(blockId: TLShapeId): Record<string, unknown> {
|
||||||
|
const inputs: Record<string, unknown> = {}
|
||||||
|
const bindings = getBlockInputBindings(this.editor, blockId)
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const sourceBlock = this.editor.getShape(
|
||||||
|
binding.fromShapeId
|
||||||
|
) as IWorkflowBlock | undefined
|
||||||
|
|
||||||
|
if (sourceBlock && sourceBlock.type === 'WorkflowBlock') {
|
||||||
|
const sourceOutputs = sourceBlock.props.outputValues
|
||||||
|
if (binding.fromPortId in sourceOutputs) {
|
||||||
|
inputs[binding.toPortId] = sourceOutputs[binding.fromPortId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include any static input values
|
||||||
|
const block = this.editor.getShape(blockId) as IWorkflowBlock | undefined
|
||||||
|
if (block && block.type === 'WorkflowBlock') {
|
||||||
|
for (const [key, value] of Object.entries(block.props.inputValues)) {
|
||||||
|
if (!(key in inputs)) {
|
||||||
|
inputs[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowPropagator
|
||||||
|
|
@ -65,16 +65,15 @@ import { GoogleItemTool } from "@/tools/GoogleItemTool"
|
||||||
import { MapShape } from "@/shapes/MapShapeUtil"
|
import { MapShape } from "@/shapes/MapShapeUtil"
|
||||||
import { MapTool } from "@/tools/MapTool"
|
import { MapTool } from "@/tools/MapTool"
|
||||||
// Workflow Builder - Flowy-like workflow blocks
|
// Workflow Builder - Flowy-like workflow blocks
|
||||||
// TODO: Fix TypeScript errors in workflow files before re-enabling
|
import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
|
||||||
// import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
|
import { WorkflowBlockTool } from "@/tools/WorkflowBlockTool"
|
||||||
// import { WorkflowBlockTool } from "@/tools/WorkflowBlockTool"
|
|
||||||
// Calendar - Unified calendar with view switching (browser, widget, year)
|
// Calendar - Unified calendar with view switching (browser, widget, year)
|
||||||
import { CalendarShape } from "@/shapes/CalendarShapeUtil"
|
import { CalendarShape } from "@/shapes/CalendarShapeUtil"
|
||||||
import { CalendarTool } from "@/tools/CalendarTool"
|
import { CalendarTool } from "@/tools/CalendarTool"
|
||||||
import { CalendarEventShape } from "@/shapes/CalendarEventShapeUtil"
|
import { CalendarEventShape } from "@/shapes/CalendarEventShapeUtil"
|
||||||
// TODO: Fix TypeScript errors in workflow files before re-enabling
|
// Workflow propagator for real-time data flow
|
||||||
// import { registerWorkflowPropagator } from "@/propagators/WorkflowPropagator"
|
import { registerWorkflowPropagator } from "@/propagators/WorkflowPropagator"
|
||||||
// import { setupBlockExecutionListener } from "@/lib/workflow/executor"
|
import { setupBlockExecutionListener } from "@/lib/workflow/executor"
|
||||||
import {
|
import {
|
||||||
lockElement,
|
lockElement,
|
||||||
unlockElement,
|
unlockElement,
|
||||||
|
|
@ -176,7 +175,7 @@ const customShapeUtils = [
|
||||||
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
||||||
GoogleItemShape, // Individual items from Google Export with privacy badges
|
GoogleItemShape, // Individual items from Google Export with privacy badges
|
||||||
MapShape, // Open Mapping - OSM map shape
|
MapShape, // Open Mapping - OSM map shape
|
||||||
// WorkflowBlockShape, // Workflow Builder - Flowy-like blocks (disabled - TypeScript errors)
|
WorkflowBlockShape, // Workflow Builder - Flowy-like blocks
|
||||||
CalendarShape, // Calendar - Unified with view switching (browser/widget/year)
|
CalendarShape, // Calendar - Unified with view switching (browser/widget/year)
|
||||||
CalendarEventShape, // Calendar - Individual event cards
|
CalendarEventShape, // Calendar - Individual event cards
|
||||||
]
|
]
|
||||||
|
|
@ -201,7 +200,7 @@ const customTools = [
|
||||||
PrivateWorkspaceTool,
|
PrivateWorkspaceTool,
|
||||||
GoogleItemTool,
|
GoogleItemTool,
|
||||||
MapTool, // Open Mapping - OSM map tool
|
MapTool, // Open Mapping - OSM map tool
|
||||||
// WorkflowBlockTool, // Workflow Builder - click-to-place (disabled - TypeScript errors)
|
WorkflowBlockTool, // Workflow Builder - click-to-place
|
||||||
CalendarTool, // Calendar - Unified with view switching
|
CalendarTool, // Calendar - Unified with view switching
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1376,9 +1375,8 @@ export function Board() {
|
||||||
])
|
])
|
||||||
|
|
||||||
// Register workflow propagator for real-time data flow
|
// Register workflow propagator for real-time data flow
|
||||||
// TODO: Fix TypeScript errors in workflow files before re-enabling
|
const cleanupWorkflowPropagator = registerWorkflowPropagator(editor)
|
||||||
// const cleanupWorkflowPropagator = registerWorkflowPropagator(editor)
|
const cleanupBlockExecution = setupBlockExecutionListener(editor)
|
||||||
// const cleanupBlockExecution = setupBlockExecutionListener(editor)
|
|
||||||
|
|
||||||
// Clean up corrupted shapes that cause "No nearest point found" errors
|
// Clean up corrupted shapes that cause "No nearest point found" errors
|
||||||
// This typically happens with draw/line shapes that have no points
|
// This typically happens with draw/line shapes that have no points
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,513 @@
|
||||||
|
/**
|
||||||
|
* WorkflowBlockShapeUtil
|
||||||
|
*
|
||||||
|
* A visual workflow block shape with typed input/output ports.
|
||||||
|
* Supports connection to other blocks via tldraw arrows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BaseBoxShapeUtil,
|
||||||
|
Geometry2d,
|
||||||
|
HTMLContainer,
|
||||||
|
Rectangle2d,
|
||||||
|
TLBaseShape,
|
||||||
|
Vec,
|
||||||
|
Editor,
|
||||||
|
TLShapeId,
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
|
||||||
|
const PortLabel: React.FC<PortLabelProps> = ({ port, direction, index }) => {
|
||||||
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } = shape.props
|
||||||
|
|
||||||
|
const definition = useMemo(() => {
|
||||||
|
if (!hasBlockDefinition(blockType)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return getBlockDefinition(blockType)
|
||||||
|
}, [blockType])
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView, { position: 'current' })
|
||||||
|
|
||||||
|
const { isMaximized, toggleMaximize } = useMaximize({
|
||||||
|
editor: this.editor,
|
||||||
|
shapeId: shape.id,
|
||||||
|
currentW: shape.props.w,
|
||||||
|
currentH: shape.props.h,
|
||||||
|
shapeType: 'WorkflowBlock',
|
||||||
|
padding: 40,
|
||||||
|
})
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
this.editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: shape.id,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: { executionState: 'running' },
|
||||||
|
})
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('workflow:execute-block', {
|
||||||
|
detail: { blockId: shape.id },
|
||||||
|
}))
|
||||||
|
}, [shape.id])
|
||||||
|
|
||||||
|
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]
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6b7280',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{definition.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: maxPorts * PORT_SPACING + 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{executionError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#fee2e2',
|
||||||
|
color: '#991b1b',
|
||||||
|
fontSize: 11,
|
||||||
|
borderTop: '1px solid #fecaca',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{executionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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) {
|
||||||
|
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 const WorkflowBlockShape = WorkflowBlockShapeUtil
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
/**
|
||||||
|
* 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, createShapeId } from 'tldraw'
|
||||||
|
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 = () => {
|
||||||
|
this.editor.setCursor({ type: 'cross', rotation: 0 })
|
||||||
|
|
||||||
|
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 || '?'
|
||||||
|
|
||||||
|
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;
|
||||||
|
`
|
||||||
|
|
||||||
|
if (categoryInfo) {
|
||||||
|
const indicator = document.createElement('span')
|
||||||
|
indicator.style.cssText = `
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${categoryInfo.color};
|
||||||
|
`
|
||||||
|
this.tooltipElement.appendChild(indicator)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textSpan = document.createElement('span')
|
||||||
|
textSpan.textContent = `${icon} Click to place ${blockName}`
|
||||||
|
this.tooltipElement.appendChild(textSpan)
|
||||||
|
|
||||||
|
document.body.appendChild(this.tooltipElement)
|
||||||
|
|
||||||
|
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||||
|
if (this.tooltipElement) {
|
||||||
|
const x = e.clientX + 15
|
||||||
|
const y = e.clientY - 40
|
||||||
|
|
||||||
|
const rect = this.tooltipElement.getBoundingClientRect()
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
|
let finalX = x
|
||||||
|
let finalY = y
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const finalX = clickX - shapeWidth / 2
|
||||||
|
const finalY = clickY - shapeHeight / 2
|
||||||
|
|
||||||
|
// Create a unique ID for the shape
|
||||||
|
const shapeId = createShapeId()
|
||||||
|
|
||||||
|
this.editor.createShape({
|
||||||
|
id: shapeId,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.editor.setSelectedShapes([shapeId])
|
||||||
|
this.editor.setCurrentTool('select')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating WorkflowBlock shape:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowBlockTool
|
||||||
|
|
@ -14,8 +14,8 @@ import { createShapeId } from "tldraw"
|
||||||
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
||||||
import { HolonData } from "../lib/HoloSphereService"
|
import { HolonData } from "../lib/HoloSphereService"
|
||||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||||
// TODO: Fix TypeScript errors in workflow files before re-enabling
|
// Workflow Builder palette
|
||||||
// import { WorkflowPalette } from "../components/workflow/WorkflowPalette"
|
import WorkflowPalette from "../components/workflow/WorkflowPalette"
|
||||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||||
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
|
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
|
||||||
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types"
|
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types"
|
||||||
|
|
@ -793,14 +793,13 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["calendar"].id === editor.getCurrentToolId()}
|
isSelected={tools["calendar"].id === editor.getCurrentToolId()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Workflow Builder - Toggle Palette (disabled - TypeScript errors)
|
{/* Workflow Builder - Toggle Palette */}
|
||||||
<TldrawUiMenuItem
|
<TldrawUiMenuItem
|
||||||
id="workflow-palette"
|
id="workflow-palette"
|
||||||
icon="sticker"
|
icon="sticker"
|
||||||
label="Workflow Blocks"
|
label="Workflow Blocks"
|
||||||
onSelect={() => setShowWorkflowPalette(!showWorkflowPalette)}
|
onSelect={() => setShowWorkflowPalette(!showWorkflowPalette)}
|
||||||
/>
|
/>
|
||||||
*/}
|
|
||||||
{/* Refresh All ObsNotes Button */}
|
{/* Refresh All ObsNotes Button */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const allShapes = editor.getCurrentPageShapes()
|
const allShapes = editor.getCurrentPageShapes()
|
||||||
|
|
@ -828,12 +827,12 @@ export function CustomToolbar() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Workflow Builder Palette (disabled - TypeScript errors)
|
{/* Workflow Builder Palette */}
|
||||||
<WorkflowPalette
|
<WorkflowPalette
|
||||||
|
editor={editor}
|
||||||
isOpen={showWorkflowPalette}
|
isOpen={showWorkflowPalette}
|
||||||
onClose={() => setShowWorkflowPalette(false)}
|
onClose={() => setShowWorkflowPalette(false)}
|
||||||
/>
|
/>
|
||||||
*/}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue