Completely set up scaffolding for project blocks

This commit is contained in:
Emir Karabeg
2025-01-10 16:29:15 -08:00
parent de1a71b88e
commit 0b82426bac
19 changed files with 386 additions and 297 deletions

View File

@@ -1,8 +1,16 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { BlockConfig, BLOCKS } from '../components/block/blocks'
import { WorkflowBlock } from '../components/block/workflow-block'
import { BlockConfig } from '../components/blocks/types/block'
import { WorkflowBlock } from '../components/blocks/components/workflow-block/workflow-block'
import { getBlock } from '../components/blocks/configs'
interface WorkflowBlock {
id: string
type: string
position: { x: number; y: number }
config: BlockConfig
}
const ZOOM_SPEED = 0.005
const MIN_ZOOM = 0.5
@@ -10,14 +18,7 @@ const MAX_ZOOM = 2
const CANVAS_SIZE = 5000 // 5000px x 5000px virtual canvas
export default function Workflow() {
const [blocks, setBlocks] = useState<
{
id: string
position: { x: number; y: number }
type: string
config: BlockConfig
}[]
>([])
const [blocks, setBlocks] = useState<WorkflowBlock[]>([])
const [zoom, setZoom] = useState(1)
const [pan, setPan] = useState({ x: 0, y: 0 })
const [isPanning, setIsPanning] = useState(false)
@@ -66,29 +67,28 @@ export default function Workflow() {
e.preventDefault()
try {
const blockData = JSON.parse(
e.dataTransfer.getData('application/json')
) as BlockConfig
const { type } = JSON.parse(e.dataTransfer.getData('application/json'))
const blockConfig = getBlock(type)
if (!blockConfig) {
console.error('Invalid block type:', type)
return
}
// Get the canvas element's bounding rectangle
const rect = e.currentTarget.getBoundingClientRect()
// Calculate the drop position in canvas coordinates
// 1. Get the mouse position relative to the canvas element
// 2. Remove the pan offset (scaled by zoom)
// 3. Scale by zoom to get true canvas coordinates
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
const x = mouseX / zoom
const y = mouseY / zoom
setBlocks((prev: any) => [
setBlocks((prev) => [
...prev,
{
...blockData,
id: crypto.randomUUID(),
type,
position: { x, y },
config: blockConfig,
},
])
} catch (err) {
@@ -200,20 +200,16 @@ export default function Workflow() {
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{blocks.map((block, index) => {
const blockConfig =
BLOCKS.find((b) => b.type === block.type) || block.config
return (
<WorkflowBlock
key={block.id}
id={block.id}
type={block.type}
position={block.position}
config={blockConfig}
name={`${blockConfig.toolbar.title} ${index + 1}`}
/>
)
})}
{blocks.map((block, index) => (
<WorkflowBlock
key={block.id}
id={block.id}
type={block.type}
position={block.position}
config={block.config}
name={`${block.config.toolbar.title} ${index + 1}`}
/>
))}
</div>
</div>
)

View File

@@ -1,128 +0,0 @@
import { AgentIcon, ApiIcon, ConditionalIcon } from '@/components/icons'
export interface SubBlockConfig {
title: string
type: 'short-text' | 'long-text' | 'dropdown' | 'slider' | 'group'
options?: string[] // For dropdown
min?: number // For slider
max?: number // For slider
layout?: 'full' | 'half' // Controls if the block takes full width or shares space
}
export interface BlockConfig {
type: string
toolbar: {
title: string
description: string
bgColor: string
icon: any
category: 'basic' | 'advanced'
}
workflow: {
inputs: { [key: string]: string }
outputs: { [key: string]: string }
subBlocks: SubBlockConfig[]
}
}
export const BLOCKS: BlockConfig[] = [
{
type: 'agent',
toolbar: {
title: 'Agent',
description: 'Use any LLM',
bgColor: '#7F2FFF',
icon: AgentIcon,
category: 'basic',
},
workflow: {
inputs: {
prompt: 'string',
},
outputs: {
response: 'string',
},
subBlocks: [
{
title: 'System Prompt',
type: 'long-text',
layout: 'full',
},
{
title: 'Model',
type: 'dropdown',
layout: 'half',
options: ['GPT-4', 'GPT-3.5', 'Claude'],
},
{
title: 'Temperature',
type: 'slider',
layout: 'half',
min: 0,
max: 2,
},
],
},
},
{
type: 'api',
toolbar: {
title: 'API',
description: 'Connect to any API',
bgColor: '#2F55FF',
icon: ApiIcon,
category: 'basic',
},
workflow: {
inputs: {
url: 'string',
method: 'string',
},
outputs: {
response: 'string',
},
subBlocks: [
{
title: 'URL',
type: 'long-text',
},
{
title: 'Method',
type: 'dropdown',
options: ['GET', 'POST', 'PUT', 'DELETE'],
},
],
},
},
{
type: 'conditional',
toolbar: {
title: 'Conditional',
description: 'Create branching logic',
bgColor: '#FF972F',
icon: ConditionalIcon,
category: 'basic',
},
workflow: {
inputs: {
// Add conditional-specific inputs
},
outputs: {
// Add conditional-specific outputs
},
subBlocks: [
{
title: 'Condition',
type: 'dropdown',
options: ['True', 'False'],
},
{
title: 'Action',
type: 'dropdown',
options: ['Do Something', 'Do Nothing'],
},
],
},
},
]

View File

@@ -1,108 +0,0 @@
import { Input } from '@/components/ui/input'
import { SubBlockConfig } from './blocks'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { Label } from '@/components/ui/label'
import { useState } from 'react'
interface SubBlockProps {
config: SubBlockConfig
}
export function SubBlock({ config }: SubBlockProps) {
const [sliderValue, setSliderValue] = useState(
config.type === 'slider'
? (config.min || 0) + ((config.max || 100) - (config.min || 0)) / 2
: 0
)
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const renderInput = () => {
switch (config.type) {
case 'short-text':
return <Input className="w-full" />
case 'long-text':
return <Textarea className="w-full resize-none" rows={3} />
case 'dropdown':
return (
<div onMouseDown={handleMouseDown}>
<Select defaultValue={config.options?.[0]}>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{config.options?.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
case 'slider':
return (
<div className="relative pt-2 pb-6">
<Slider
defaultValue={[
config.type === 'slider'
? (config.min || 0) +
((config.max || 100) - (config.min || 0)) / 2
: 0,
]}
min={config.min}
max={config.max}
step={0.1}
onValueChange={(value) => setSliderValue(value[0])}
className="[&_[role=slider]]:h-4 [&_[role=slider]]:w-4 [&_[class*=SliderTrack]]:h-1"
/>
<div
className="absolute text-sm text-muted-foreground"
style={{
left: `clamp(0%, ${
((sliderValue - (config.min || 0)) /
((config.max || 100) - (config.min || 0))) *
100
}%, 100%)`,
transform: `translateX(-${
((sliderValue - (config.min || 0)) /
((config.max || 100) - (config.min || 0))) *
100 ===
0
? 0
: ((sliderValue - (config.min || 0)) /
((config.max || 100) - (config.min || 0))) *
100 ===
100
? 100
: 50
}%)`,
top: '24px',
}}
>
{sliderValue.toFixed(1)}
</div>
</div>
)
default:
return null
}
}
return (
<div className="space-y-1" onMouseDown={handleMouseDown}>
<Label>{config.title}</Label>
{renderInput()}
</div>
)
}

View File

@@ -1,13 +1,15 @@
import type { BlockConfig } from './blocks'
import type { BlockConfig } from '../../types/block'
export type ToolbarBlockProps = {
type: string
toolbar: BlockConfig['toolbar']
config: BlockConfig
}
export function ToolbarBlock({ type, toolbar }: ToolbarBlockProps) {
export function ToolbarBlock({ config }: ToolbarBlockProps) {
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData('application/json', JSON.stringify({ type }))
e.dataTransfer.setData(
'application/json',
JSON.stringify({ type: config.type })
)
}
return (
@@ -18,17 +20,19 @@ export function ToolbarBlock({ type, toolbar }: ToolbarBlockProps) {
>
<div
className="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ backgroundColor: toolbar.bgColor }}
style={{ backgroundColor: config.toolbar.bgColor }}
>
<toolbar.icon
<config.toolbar.icon
className={`text-white transition-transform duration-200 group-hover:scale-110 ${
type === 'agent' ? 'w-[24px] h-[24px]' : 'w-[22px] h-[22px]'
config.type === 'agent' ? 'w-[24px] h-[24px]' : 'w-[22px] h-[22px]'
}`}
/>
</div>
<div className="flex flex-col gap-1">
<h3 className="font-medium leading-none">{toolbar.title}</h3>
<p className="text-sm text-muted-foreground">{toolbar.description}</p>
<h3 className="font-medium leading-none">{config.toolbar.title}</h3>
<p className="text-sm text-muted-foreground">
{config.toolbar.description}
</p>
</div>
</div>
)

View File

@@ -0,0 +1,29 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface DropdownProps {
options: string[]
defaultValue?: string
}
export function Dropdown({ options, defaultValue }: DropdownProps) {
return (
<Select defaultValue={defaultValue ?? options[0]}>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@@ -0,0 +1,5 @@
import { Textarea } from '@/components/ui/textarea'
export function LongInput() {
return <Textarea className="w-full resize-none" rows={3} />
}

View File

@@ -0,0 +1,5 @@
import { Input } from '@/components/ui/input'
export function ShortInput() {
return <Input className="w-full" />
}

View File

@@ -0,0 +1,47 @@
import { Slider } from '@/components/ui/slider'
import { useState } from 'react'
interface SliderInputProps {
min?: number
max?: number
defaultValue: number
}
export function SliderInput({
min = 0,
max = 100,
defaultValue,
}: SliderInputProps) {
const [sliderValue, setSliderValue] = useState(defaultValue)
return (
<div className="relative pt-2 pb-6">
<Slider
defaultValue={[defaultValue]}
min={min}
max={max}
step={0.1}
onValueChange={(value) => setSliderValue(value[0])}
className="[&_[role=slider]]:h-4 [&_[role=slider]]:w-4 [&_[class*=SliderTrack]]:h-1"
/>
<div
className="absolute text-sm text-muted-foreground"
style={{
left: `clamp(0%, ${
((sliderValue - min) / (max - min)) * 100
}%, 100%)`,
transform: `translateX(-${
((sliderValue - min) / (max - min)) * 100 === 0
? 0
: ((sliderValue - min) / (max - min)) * 100 === 100
? 100
: 50
}%)`,
top: '24px',
}}
>
{sliderValue.toFixed(1)}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { SubBlockConfig } from '../../../types/block'
import { Label } from '@/components/ui/label'
import { ShortInput } from './components/short-input'
import { LongInput } from './components/long-input'
import { Dropdown } from './components/dropdown'
import { SliderInput } from './components/slider-input'
interface SubBlockProps {
config: SubBlockConfig
}
export function SubBlock({ config }: SubBlockProps) {
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const renderInput = () => {
switch (config.type) {
case 'short-input':
return <ShortInput />
case 'long-input':
return <LongInput />
case 'dropdown':
return (
<div onMouseDown={handleMouseDown}>
<Dropdown options={config.options ?? []} />
</div>
)
case 'slider':
return (
<SliderInput
min={config.min}
max={config.max}
defaultValue={
(config.min || 0) + ((config.max || 100) - (config.min || 0)) / 2
}
/>
)
default:
return null
}
}
return (
<div className="space-y-1" onMouseDown={handleMouseDown}>
<Label>{config.title}</Label>
{renderInput()}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { Card } from '@/components/ui/card'
import { BlockConfig, SubBlockConfig } from './blocks'
import { BlockConfig, SubBlockConfig } from '../../types/block'
import { cn } from '@/lib/utils'
import { SubBlock } from './sub-block'
import { SubBlock } from './sub-block/sub-block'
export interface WorkflowBlockProps {
id: string
@@ -69,7 +69,7 @@ export function WorkflowBlock({
<div className="px-4 pt-2 pb-4 space-y-4">
{subBlockRows.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} className="flex gap-2">
<div key={`row-${rowIndex}`} className="flex gap-4">
{row.map((subBlock, blockIndex) => (
<div
key={`${id}-${rowIndex}-${blockIndex}`}

View File

@@ -0,0 +1,43 @@
import { AgentIcon } from '@/components/icons'
import { BlockConfig } from '../types/block'
export const AgentBlock: BlockConfig = {
type: 'agent',
toolbar: {
title: 'Agent',
description: 'Use any LLM',
bgColor: '#7F2FFF',
icon: AgentIcon,
category: 'basic',
},
workflow: {
inputs: {
prompt: 'string',
context: 'string',
},
outputs: {
response: 'string',
tokens: 'number',
},
subBlocks: [
{
title: 'System Prompt',
type: 'long-input',
layout: 'full',
},
{
title: 'Model',
type: 'dropdown',
layout: 'half',
options: ['GPT-4', 'GPT-3.5', 'Claude', 'Gemini'],
},
{
title: 'Temperature',
type: 'slider',
layout: 'half',
min: 0,
max: 2,
},
],
},
}

View File

@@ -0,0 +1,48 @@
import { ApiIcon } from '@/components/icons'
import { BlockConfig } from '../types/block'
export const ApiBlock: BlockConfig = {
type: 'api',
toolbar: {
title: 'API',
description: 'Use any API',
bgColor: '#2F55FF',
icon: ApiIcon,
category: 'basic',
},
workflow: {
inputs: {
url: 'string',
method: 'string',
headers: 'object',
body: 'string',
},
outputs: {
response: 'string',
statusCode: 'number',
},
subBlocks: [
{
title: 'URL',
type: 'short-input',
layout: 'full',
},
{
title: 'Method',
type: 'dropdown',
layout: 'half',
options: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
},
{
title: 'Headers',
type: 'long-input',
layout: 'full',
},
{
title: 'Body',
type: 'long-input',
layout: 'full',
},
],
},
}

View File

@@ -0,0 +1,42 @@
import { ConditionalIcon } from '@/components/icons'
import { BlockConfig } from '../types/block'
export const ConditionalBlock: BlockConfig = {
type: 'conditional',
toolbar: {
title: 'Conditional',
description: 'Add branching logic',
bgColor: '#FF972F',
icon: ConditionalIcon,
category: 'basic',
},
workflow: {
inputs: {
condition: 'boolean',
value: 'any',
},
outputs: {
result: 'any',
path: 'string',
},
subBlocks: [
{
title: 'Condition Type',
type: 'dropdown',
layout: 'full',
options: [
'Equals',
'Contains',
'Greater Than',
'Less Than',
'Regular Expression',
],
},
{
title: 'Value',
type: 'short-input',
layout: 'full',
},
],
},
}

View File

@@ -0,0 +1,27 @@
import { BlockConfig, BlockType } from '../types/block'
import { AgentBlock } from './agent'
import { ApiBlock } from './api'
import { ConditionalBlock } from './conditional'
// Export individual blocks
export { AgentBlock, ApiBlock, ConditionalBlock }
// Combined blocks registry
export const BLOCKS: BlockConfig[] = [
AgentBlock,
ApiBlock,
ConditionalBlock,
]
// Helper functions
export const getBlock = (type: BlockType): BlockConfig | undefined =>
BLOCKS.find(block => block.type === type)
export const getBlocksByCategory = (category: 'basic' | 'advanced'): BlockConfig[] =>
BLOCKS.filter(block => block.toolbar.category === category)
export const getAllBlockTypes = (): BlockType[] =>
BLOCKS.map(block => block.type)
export const isValidBlockType = (type: string): type is BlockType =>
BLOCKS.some(block => block.type === type)

View File

@@ -0,0 +1,2 @@
export * from './types/block'
export * from './configs'

View File

@@ -0,0 +1,33 @@
import type { SVGProps } from 'react'
import type { JSX } from 'react'
export type BlockType = 'agent' | 'api' | 'conditional'
export type BlockIcon = (props: SVGProps<SVGSVGElement>) => JSX.Element
export type BlockCategory = 'basic' | 'advanced'
export type SubBlockType = 'short-input' | 'long-input' | 'dropdown' | 'slider'
export type SubBlockLayout = 'full' | 'half'
export interface SubBlockConfig {
title: string
type: SubBlockType
options?: string[]
min?: number
max?: number
layout?: SubBlockLayout
}
export interface BlockConfig {
type: BlockType
toolbar: {
title: string
description: string
bgColor: string
icon: BlockIcon
category: BlockCategory
}
workflow: {
inputs: Record<string, string>
outputs: Record<string, string>
subBlocks: SubBlockConfig[]
}
}

View File

@@ -2,11 +2,11 @@
import { useState } from 'react'
import { ToolbarTabs } from './toolbar-tabs'
import { ToolbarBlock } from '../block/toolbar-block'
import { BLOCKS } from '../block/blocks'
import { ToolbarBlock } from '../blocks/components/toolbar-block/toolbar-block'
import { getBlocksByCategory, BlockCategory } from '../blocks'
export function Toolbar() {
const [activeTab, setActiveTab] = useState<'basic' | 'advanced'>('basic')
const [activeTab, setActiveTab] = useState<BlockCategory>('basic')
return (
<div className="fixed left-14 top-0 z-1 hidden h-full w-72 border-r bg-background sm:block">
@@ -14,15 +14,9 @@ export function Toolbar() {
<div className="p-4">
<div className="flex flex-col gap-3">
{BLOCKS.filter((block) => block.toolbar.category === activeTab).map(
(block) => (
<ToolbarBlock
key={block.type}
type={block.type}
toolbar={block.toolbar}
/>
)
)}
{getBlocksByCategory(activeTab).map((block) => (
<ToolbarBlock key={block.type} config={block} />
))}
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import Providers from './providers'
import { Toolbar } from './components/toolbar/toolbar'
import { ControlBar } from './components/control-bar'
import { ControlBar } from './components/control-bar/control-bar'
import { Sidebar } from './components/sidebar/sidebar'
export default function WorkspaceLayout({