canvas-website/src/lib/workflow/serialization.ts

660 lines
16 KiB
TypeScript

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