chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
import type { PreviewWorkflow } from './workflow-data'
|
||||
|
||||
/**
|
||||
* Workflows shown in the academy videos, reproduced block-for-block so each
|
||||
* page's written supplement shows the same machine the video builds. Rows and
|
||||
* operation labels match the videos (which match the block registry).
|
||||
*/
|
||||
|
||||
/** files/intro + files/object — the invoice intake machine. */
|
||||
export const AV_INVOICE_INTAKE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-invoice-intake',
|
||||
name: 'Invoice intake',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gmailtrigger',
|
||||
name: 'Gmail Email Trigger',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Include Attachments', value: 'Enabled' }],
|
||||
},
|
||||
{
|
||||
id: 'file',
|
||||
name: 'File',
|
||||
type: 'file',
|
||||
bgColor: '#40916C',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Read' },
|
||||
{ title: 'Files', value: '<gmailtrigger.attachments[0]>' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 680, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: 'Extract the invoice fields' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Files', value: '<file.files[0]>' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supabase',
|
||||
name: 'Supabase',
|
||||
type: 'supabase',
|
||||
bgColor: '#1C1C1C',
|
||||
position: { x: 1020, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Create a Row' },
|
||||
{ title: 'Table', value: 'invoices' },
|
||||
{ title: 'Data', value: '<agent.content>' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'trigger-file', source: 'gmailtrigger', target: 'file' },
|
||||
{ id: 'file-agent', source: 'file', target: 'agent' },
|
||||
{ id: 'agent-supabase', source: 'agent', target: 'supabase' },
|
||||
],
|
||||
}
|
||||
|
||||
/** agents/block — the Qualify agent the camera rides through. */
|
||||
export const AV_QUALIFY_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-qualify',
|
||||
name: 'Qualify a lead',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Lead' }],
|
||||
},
|
||||
{
|
||||
id: 'qualify',
|
||||
name: 'Qualify',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'response',
|
||||
name: 'Response',
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 680, y: 0 },
|
||||
rows: [{ title: 'Data', value: '<qualify.content>' }],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-qualify', source: 'start', target: 'qualify' },
|
||||
{ id: 'qualify-response', source: 'qualify', target: 'response' },
|
||||
],
|
||||
}
|
||||
|
||||
/** agents/memory — the Support agent with Memory set to Conversation. */
|
||||
export const AV_MEMORY_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-memory',
|
||||
name: 'Support agent with memory',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Customer message' }],
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
name: 'Support',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: '<start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Memory', value: 'Conversation · user-123' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'start-support', source: 'start', target: 'support' }],
|
||||
}
|
||||
|
||||
/** tables/operations — the Table block with a filtered query. */
|
||||
export const AV_TABLE_OPS_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-table-ops',
|
||||
name: 'Query the tickets table',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Run' }],
|
||||
},
|
||||
{
|
||||
id: 'table',
|
||||
name: 'Table 1',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Query Rows' },
|
||||
{ title: 'Table', value: 'tickets' },
|
||||
{ title: 'Filter Conditions', value: 'priority equals high' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'start-table', source: 'start', target: 'table' }],
|
||||
}
|
||||
|
||||
/** agents/tool-calling — the Qualify agent with research tools attached. */
|
||||
export const AV_TOOLS_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-tools',
|
||||
name: 'Qualify with tools',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Lead' }],
|
||||
},
|
||||
{
|
||||
id: 'qualify',
|
||||
name: 'Qualify',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
tools: [
|
||||
{ type: 'exa', name: 'Search', bgColor: '#1F40ED' },
|
||||
{ type: 'github', name: 'GitHub', bgColor: '#181717' },
|
||||
{ type: 'hubspot', name: 'CRM', bgColor: '#FF7A59' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'response',
|
||||
name: 'Response',
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 680, y: 0 },
|
||||
rows: [{ title: 'Data', value: '<qualify.content>' }],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-qualify', source: 'start', target: 'qualify' },
|
||||
{ id: 'qualify-response', source: 'qualify', target: 'response' },
|
||||
],
|
||||
}
|
||||
|
||||
/** agents/skills — the same agent with a skill attached. */
|
||||
export const AV_SKILLS_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-skills',
|
||||
name: 'Qualify with a skill',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Lead' }],
|
||||
},
|
||||
{
|
||||
id: 'qualify',
|
||||
name: 'Qualify',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Skills', value: 'answer-with-citations' },
|
||||
],
|
||||
tools: [{ type: 'exa', name: 'Search', bgColor: '#1F40ED' }],
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'start-qualify', source: 'start', target: 'qualify' }],
|
||||
}
|
||||
|
||||
/** chat/intro — the support-desk workflow the chat operates. */
|
||||
export const AV_SUPPORT_DESK_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-support-desk',
|
||||
name: 'support-desk',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Ticket' }],
|
||||
},
|
||||
{
|
||||
id: 'triage',
|
||||
name: 'Triage',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 320, y: 60 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: '<start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
tools: [{ type: 'knowledge', name: 'Help Center', bgColor: '#00B0B0' }],
|
||||
},
|
||||
{
|
||||
id: 'condition',
|
||||
name: 'Urgent?',
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
position: { x: 660, y: 60 },
|
||||
rows: [],
|
||||
branches: [
|
||||
{ id: 'condition-if', label: 'If', value: '<triage.urgent>' },
|
||||
{ id: 'condition-else', label: 'else' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'escalate',
|
||||
name: 'Escalate',
|
||||
type: 'slack',
|
||||
bgColor: '#611F69',
|
||||
position: { x: 1000, y: -40 },
|
||||
rows: [{ title: 'Channel', value: '#support-urgent' }],
|
||||
},
|
||||
{
|
||||
id: 'reply',
|
||||
name: 'Reply',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 1000, y: 160 },
|
||||
rows: [{ title: 'Messages', value: 'Draft the reply' }],
|
||||
},
|
||||
{
|
||||
id: 'log',
|
||||
name: 'Log',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 1340, y: 60 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Insert Row' },
|
||||
{ title: 'Table', value: 'tickets' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-triage', source: 'start', target: 'triage' },
|
||||
{ id: 'triage-condition', source: 'triage', target: 'condition' },
|
||||
{
|
||||
id: 'condition-escalate',
|
||||
source: 'condition',
|
||||
target: 'escalate',
|
||||
sourceHandle: 'condition-if',
|
||||
},
|
||||
{ id: 'condition-reply', source: 'condition', target: 'reply', sourceHandle: 'condition-else' },
|
||||
{ id: 'escalate-log', source: 'escalate', target: 'log' },
|
||||
{ id: 'reply-log', source: 'reply', target: 'log' },
|
||||
],
|
||||
}
|
||||
|
||||
/** chat/building — the content-agent the chat builds: candidates drafted in
|
||||
* parallel, media generated, an evaluator scoring, results kept. */
|
||||
export const AV_CONTENT_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-content-agent',
|
||||
name: 'content-agent',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 95 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Idea' }],
|
||||
},
|
||||
{
|
||||
id: 'candidates',
|
||||
name: 'Candidates',
|
||||
type: 'parallel',
|
||||
bgColor: '#1D1C1A',
|
||||
position: { x: 320, y: 30 },
|
||||
size: { width: 430, height: 170 },
|
||||
rows: [],
|
||||
},
|
||||
{
|
||||
id: 'writer',
|
||||
name: 'Writer',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 150, y: 62 },
|
||||
parentId: 'candidates',
|
||||
rows: [
|
||||
{ title: 'Messages', value: '<start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evaluator',
|
||||
name: 'Evaluator',
|
||||
type: 'evaluator',
|
||||
bgColor: '#8B5CF6',
|
||||
position: { x: 840, y: 95 },
|
||||
rows: [
|
||||
{ title: 'Metrics', value: 'Voice · Hook' },
|
||||
{ title: 'Content', value: '<writer.content>' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'scores',
|
||||
name: 'Scores',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 1180, y: 95 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Insert Row' },
|
||||
{ title: 'Table', value: 'scores' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-candidates', source: 'start', target: 'candidates' },
|
||||
{
|
||||
id: 'candidates-writer',
|
||||
source: 'candidates',
|
||||
target: 'writer',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
{ id: 'candidates-evaluator', source: 'candidates', target: 'evaluator' },
|
||||
{ id: 'evaluator-scores', source: 'evaluator', target: 'scores' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import type { PreviewWorkflow } from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
/**
|
||||
* Single-block preview workflows for the block reference heroes — one per block,
|
||||
* authored to match exactly what the builder canvas shows. Source of truth for
|
||||
* `<BlockPreview type="...">`.
|
||||
*
|
||||
* Each entry is a one-block {@link PreviewWorkflow} rendered through the shared
|
||||
* {@link WorkflowBlockView} (via `DocsBlockNode`), so the hero stays pixel-faithful
|
||||
* to the canvas. Authoring notes:
|
||||
*
|
||||
* - `rows` are the visible sub-block rows; use `'-'` for an empty/unset field (the
|
||||
* canvas shows a dash), or a representative value where the field has a default.
|
||||
* - `branches` render one output handle per entry (Condition's if/else-if/else,
|
||||
* Router's routes). The View regenerates handle topology, so the label doubles as
|
||||
* the branch id.
|
||||
* - `hideTargetHandle: true` for triggers (entry points — no input). The View derives
|
||||
* the default target/source handles and the bottom `Error` row from this gate, so
|
||||
* there is no separate `showError`/`hideSourceHandle` flag.
|
||||
* - `bgColor` is the resolved hex; the Agent uses Sim green `#33C482` (`var(--brand)`).
|
||||
*/
|
||||
export const BLOCK_DISPLAY_WORKFLOWS: Record<string, PreviewWorkflow> = {
|
||||
agent: {
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
blocks: [
|
||||
{
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: '-' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Files', value: '-' },
|
||||
{ title: 'Tools', value: '-' },
|
||||
{ title: 'Skills', value: '-' },
|
||||
{ title: 'Memory', value: 'None' },
|
||||
{ title: 'Temperature', value: '0.7' },
|
||||
{ title: 'Response Format', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
api: {
|
||||
id: 'api',
|
||||
name: 'API',
|
||||
blocks: [
|
||||
{
|
||||
id: 'api',
|
||||
name: 'API',
|
||||
type: 'api',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'URL', value: '-' },
|
||||
{ title: 'Method', value: 'GET' },
|
||||
{ title: 'Query Params', value: '-' },
|
||||
{ title: 'Headers', value: '-' },
|
||||
{ title: 'Body', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
condition: {
|
||||
id: 'condition',
|
||||
name: 'Condition',
|
||||
blocks: [
|
||||
{
|
||||
id: 'condition',
|
||||
name: 'Condition',
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [],
|
||||
branches: [
|
||||
{ id: 'if', label: 'if' },
|
||||
{ id: 'else if', label: 'else if' },
|
||||
{ id: 'else', label: 'else' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
credential: {
|
||||
id: 'credential',
|
||||
name: 'Credential',
|
||||
blocks: [
|
||||
{
|
||||
id: 'credential',
|
||||
name: 'Credential',
|
||||
type: 'credential',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Select Credential' },
|
||||
{ title: 'Credential', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
evaluator: {
|
||||
id: 'evaluator',
|
||||
name: 'Evaluator',
|
||||
blocks: [
|
||||
{
|
||||
id: 'evaluator',
|
||||
name: 'Evaluator',
|
||||
type: 'evaluator',
|
||||
bgColor: '#4D5FFF',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Evaluation Metrics', value: '-' },
|
||||
{ title: 'Content', value: '-' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
function: {
|
||||
id: 'function',
|
||||
name: 'Function',
|
||||
blocks: [
|
||||
{
|
||||
id: 'function',
|
||||
name: 'Function',
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Language', value: 'JavaScript' },
|
||||
{ title: 'Code', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
guardrails: {
|
||||
id: 'guardrails',
|
||||
name: 'Guardrails',
|
||||
blocks: [
|
||||
{
|
||||
id: 'guardrails',
|
||||
name: 'Guardrails',
|
||||
type: 'guardrails',
|
||||
bgColor: '#3D642D',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Content to Validate', value: '-' },
|
||||
{ title: 'Validation Type', value: 'Valid JSON' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
response: {
|
||||
id: 'response',
|
||||
name: 'Response',
|
||||
blocks: [
|
||||
{
|
||||
id: 'response',
|
||||
name: 'Response',
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Response Data Mode', value: 'Builder' },
|
||||
{ title: 'Response Structure', value: '-' },
|
||||
{ title: 'Status Code', value: '-' },
|
||||
{ title: 'Response Headers', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
router: {
|
||||
id: 'router',
|
||||
name: 'Router',
|
||||
blocks: [
|
||||
{
|
||||
id: 'router',
|
||||
name: 'Router',
|
||||
type: 'router',
|
||||
bgColor: '#28C43F',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Context', value: '-' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
branches: [{ id: 'route 1', label: 'route 1' }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
variables: {
|
||||
id: 'variables',
|
||||
name: 'Variables',
|
||||
blocks: [
|
||||
{
|
||||
id: 'variables',
|
||||
name: 'Variables',
|
||||
type: 'variables',
|
||||
bgColor: '#8B5CF6',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [{ title: 'Variable Assignments', value: '-' }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
wait: {
|
||||
id: 'wait',
|
||||
name: 'Wait',
|
||||
blocks: [
|
||||
{
|
||||
id: 'wait',
|
||||
name: 'Wait',
|
||||
type: 'wait',
|
||||
bgColor: '#F59E0B',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Wait Amount', value: '10' },
|
||||
{ title: 'Unit', value: 'Seconds' },
|
||||
{ title: 'Async', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
webhook: {
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
blocks: [
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
type: 'webhook',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Webhook URL', value: '-' },
|
||||
{ title: 'Payload', value: '-' },
|
||||
{ title: 'Signing Secret', value: '-' },
|
||||
{ title: 'Additional Headers', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
workflow: {
|
||||
id: 'workflow',
|
||||
name: 'Workflow',
|
||||
blocks: [
|
||||
{
|
||||
id: 'workflow',
|
||||
name: 'Workflow',
|
||||
type: 'workflow',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Select Workflow', value: '-' },
|
||||
{ title: 'Input Variable', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
human_in_the_loop: {
|
||||
id: 'human_in_the_loop',
|
||||
name: 'Human in the Loop',
|
||||
blocks: [
|
||||
{
|
||||
id: 'human_in_the_loop',
|
||||
name: 'Human in the Loop',
|
||||
type: 'human_in_the_loop',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Display Data', value: '-' },
|
||||
{ title: 'Notification (Send URL)', value: '-' },
|
||||
{ title: 'Resume Form', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
schedule: {
|
||||
id: 'schedule',
|
||||
name: 'Schedule',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Run frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '-' },
|
||||
{ title: 'Timezone', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
rss: {
|
||||
id: 'rss',
|
||||
name: 'RSS Feed',
|
||||
blocks: [
|
||||
{
|
||||
id: 'rss',
|
||||
name: 'RSS Feed',
|
||||
type: 'rss',
|
||||
bgColor: '#F97316',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Feed URL', value: '-' }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
webhook_trigger: {
|
||||
id: 'webhook_trigger',
|
||||
name: 'Webhook',
|
||||
blocks: [
|
||||
{
|
||||
id: 'webhook_trigger',
|
||||
name: 'Webhook',
|
||||
type: 'webhook',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Webhook URL', value: '-' },
|
||||
{ title: 'Require Authentication', value: 'On' },
|
||||
{ title: 'Authentication Token', value: '-' },
|
||||
{ title: 'Secret Header Name (Optional)', value: '-' },
|
||||
{ title: 'Deduplication Field (Optional)', value: '-' },
|
||||
{ title: 'Acknowledgement', value: 'Default' },
|
||||
{ title: 'Verify Test Events', value: '-' },
|
||||
{ title: 'Input Format', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
table: {
|
||||
id: 'table',
|
||||
name: 'Table',
|
||||
blocks: [
|
||||
{
|
||||
id: 'table',
|
||||
name: 'Table',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Table', value: '-' },
|
||||
{ title: 'Event type', value: 'Row updated' },
|
||||
{ title: 'Watch columns', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { Clock, Database, Layers, Repeat, Table } from 'lucide-react'
|
||||
import {
|
||||
ApiIcon,
|
||||
ChartBarIcon,
|
||||
CodeIcon,
|
||||
ConditionalIcon,
|
||||
ConnectIcon,
|
||||
CredentialIcon,
|
||||
HumanInTheLoopIcon,
|
||||
ResponseIcon,
|
||||
RssIcon,
|
||||
ScheduleIcon,
|
||||
ShieldCheckIcon,
|
||||
VariableIcon,
|
||||
WebhookIcon,
|
||||
WorkflowIcon,
|
||||
} from '@/components/icons'
|
||||
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
|
||||
|
||||
/**
|
||||
* The two Sim-specific block glyphs we need, ported verbatim from
|
||||
* `apps/sim/components/icons.tsx` so the preview matches the real builder.
|
||||
* Other block types fall back to lucide-react stand-ins for now.
|
||||
*/
|
||||
export function StartIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='26'
|
||||
height='16'
|
||||
viewBox='0 0 26 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M7.8 13C9.23 13 10.45 12.49 11.47 11.47C12.49 10.45 13 9.23 13 7.8C13 6.37 12.49 5.15 11.47 4.13C10.45 3.11 9.23 2.6 7.8 2.6C6.37 2.6 5.15 3.11 4.13 4.13C3.11 5.15 2.6 6.37 2.6 7.8C2.6 9.23 3.11 10.45 4.13 11.47C5.15 12.49 6.37 13 7.8 13ZM7.8 15.6C5.63 15.6 3.79 14.84 2.28 13.33C0.76 11.81 0 9.97 0 7.8C0 5.63 0.76 3.79 2.28 2.28C3.79 0.76 5.63 0 7.8 0C9.75 0 11.45 0.62 12.89 1.85C14.33 3.09 15.2 4.64 15.5 6.5H24.7C25.07 6.5 25.38 6.62 25.63 6.87C25.88 7.12 26 7.43 26 7.8C26 8.17 25.87 8.48 25.63 8.73C25.38 8.98 25.07 9.1 24.7 9.1H15.5C15.2 10.96 14.33 12.51 12.89 13.75C11.44 14.98 9.75 15.6 7.8 15.6Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='21'
|
||||
height='24'
|
||||
viewBox='0 0 21 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M15.67 9.25H4.67C2.64 9.25 1 10.89 1 12.92V18.42C1 20.44 2.64 22.08 4.67 22.08H15.67C17.69 22.08 19.33 20.44 19.33 18.42V12.92C19.33 10.89 17.69 9.25 15.67 9.25Z'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M10.17 5.58C11.18 5.58 12 4.76 12 3.75C12 2.74 11.18 1.92 10.17 1.92C9.15 1.92 8.33 2.74 8.33 3.75C8.33 4.76 9.15 5.58 10.17 5.58Z'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M10.17 5.59V9.25M7.42 16.59V14.75M12.92 14.75V16.59'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Block type → glyph. Brand glyphs from the app for core blocks; lucide stand-ins for the rest. */
|
||||
export const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
starter: StartIcon,
|
||||
start_trigger: StartIcon,
|
||||
agent: AgentIcon,
|
||||
api: ApiIcon,
|
||||
condition: ConditionalIcon,
|
||||
credential: CredentialIcon,
|
||||
evaluator: ChartBarIcon,
|
||||
function: CodeIcon,
|
||||
guardrails: ShieldCheckIcon,
|
||||
human_in_the_loop: HumanInTheLoopIcon,
|
||||
response: ResponseIcon,
|
||||
router: ConnectIcon,
|
||||
variables: VariableIcon,
|
||||
wait: Clock,
|
||||
webhook: WebhookIcon,
|
||||
workflow: WorkflowIcon,
|
||||
schedule: ScheduleIcon,
|
||||
rss: RssIcon,
|
||||
loop: Repeat,
|
||||
parallel: Layers,
|
||||
knowledge_base: Database,
|
||||
knowledge: Database,
|
||||
table: Table,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a block (or tool) type to its glyph: the core-block map first, then
|
||||
* the integration icon map so diagrams can render tool chips too. Returns
|
||||
* `null` when no glyph is registered.
|
||||
*/
|
||||
export function resolveIcon(type: string): ComponentType<{ className?: string }> | null {
|
||||
return BLOCK_ICONS[type] ?? blockTypeToIconMap[type] ?? null
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ChipSelect,
|
||||
ChipSwitch,
|
||||
ChipTag,
|
||||
chipFieldSurfaceClass,
|
||||
chipFieldTextClass,
|
||||
cn,
|
||||
FieldDivider,
|
||||
Label,
|
||||
} from '@sim/emcn'
|
||||
import { BookOpen, Pencil } from 'lucide-react'
|
||||
import { resolveIcon } from '@/components/workflow-preview/block-icons'
|
||||
import { formatReferences } from '@/components/workflow-preview/format-references'
|
||||
|
||||
type FieldKind = 'select' | 'input' | 'textarea' | 'code' | 'slider' | 'toggle'
|
||||
|
||||
interface InspectorField {
|
||||
label: string
|
||||
required?: boolean
|
||||
kind?: FieldKind
|
||||
/** Shown inside the control. For 'toggle', "on"/"off". For 'slider', the number. */
|
||||
value?: string
|
||||
/** Muted placeholder when there's no value. */
|
||||
placeholder?: string
|
||||
/** Slider fill, 0–100. */
|
||||
percent?: number
|
||||
}
|
||||
|
||||
interface InspectorTool {
|
||||
type: string
|
||||
name: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
interface BlockInspectorProps {
|
||||
/** Block name in the header, e.g. "Agent 1". */
|
||||
name: string
|
||||
/** Block type, for the header icon. */
|
||||
type?: string
|
||||
color?: string
|
||||
fields: InspectorField[]
|
||||
tools?: InspectorTool[]
|
||||
/** Render as a borderless panel filling its parent (the lightbox sidebar). */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
const NOOP = () => {}
|
||||
|
||||
/**
|
||||
* Read-only facsimile of one configuration field, composed from emcn chip
|
||||
* chrome: `select`→{@link ChipSelect}, `toggle`→{@link ChipSwitch}; text fields
|
||||
* (`input`/`textarea`/`code`) render the value with `<...>`/`{{...}}` references
|
||||
* highlighted via {@link formatReferences} in the canonical chip field surface.
|
||||
* `slider` has no chip equivalent and stays a minimal app-token bar.
|
||||
*/
|
||||
function FieldControl({ field }: { field: InspectorField }) {
|
||||
const kind = field.kind ?? 'input'
|
||||
const value = field.value ?? ''
|
||||
const placeholder = field.placeholder ?? '—'
|
||||
|
||||
if (kind === 'select') {
|
||||
return (
|
||||
<ChipSelect
|
||||
fullWidth
|
||||
value={value || undefined}
|
||||
onChange={NOOP}
|
||||
placeholder={placeholder}
|
||||
options={value ? [{ value, label: value }] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'toggle') {
|
||||
const on = field.value === 'on'
|
||||
return (
|
||||
<ChipSwitch
|
||||
value={on ? 'on' : 'off'}
|
||||
onChange={NOOP}
|
||||
aria-label={field.label}
|
||||
options={[
|
||||
{ value: 'on', label: 'On' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'slider') {
|
||||
const percent = field.percent ?? 50
|
||||
return (
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<div className='relative h-[4px] flex-1 rounded-full bg-[var(--surface-5)]'>
|
||||
<div
|
||||
className='absolute inset-y-0 left-0 rounded-full bg-[var(--brand-secondary)]'
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<div
|
||||
className='-translate-y-1/2 absolute top-1/2 size-[12px] rounded-full border border-[var(--border-1)] bg-white'
|
||||
style={{ left: `calc(${percent}% - 6px)` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>{field.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// input / textarea / code: read-only value with `<...>` block references and
|
||||
// `{{...}}` environment variables highlighted, in the canonical chip chrome.
|
||||
const content = value ? (
|
||||
formatReferences(value)
|
||||
) : (
|
||||
<span className='text-[var(--text-muted)]'>{placeholder}</span>
|
||||
)
|
||||
if (kind === 'textarea' || kind === 'code') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
chipFieldSurfaceClass,
|
||||
chipFieldTextClass,
|
||||
'min-h-[60px] whitespace-pre-wrap break-words px-2 py-1.5',
|
||||
kind === 'code' && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn(chipFieldSurfaceClass, 'flex h-[30px] items-center px-2')}>
|
||||
<span className={cn(chipFieldTextClass, 'min-w-0 truncate')}>{content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorFieldRow({ field }: { field: InspectorField }) {
|
||||
return (
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<Label className='pl-0.5'>
|
||||
{field.label}
|
||||
{field.required && <span className='ml-0.5'>*</span>}
|
||||
</Label>
|
||||
<FieldControl field={field} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only facsimile of the editor's right-hand block inspector: the block
|
||||
* header, its configuration fields as static chip controls, and its
|
||||
* connections. Hand-authored per usage, like {@link WorkflowPreview} examples.
|
||||
*/
|
||||
export function BlockInspector({
|
||||
name,
|
||||
type = 'agent',
|
||||
color = '#33C482',
|
||||
fields,
|
||||
tools,
|
||||
embedded = false,
|
||||
}: BlockInspectorProps) {
|
||||
const Icon = resolveIcon(type)
|
||||
const hasTools = Boolean(tools && tools.length > 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-[var(--surface-1)]',
|
||||
embedded
|
||||
? 'flex h-full w-full flex-col overflow-y-auto'
|
||||
: 'not-prose my-6 w-full max-w-[380px] overflow-hidden rounded-xl border border-[var(--border)]'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-3 py-1.5'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<div
|
||||
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{Icon && <Icon className='size-[12px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-sm'>{name}</span>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-2 text-[var(--text-secondary)]'>
|
||||
<Pencil className='size-[14px]' />
|
||||
<BookOpen className='size-[14px]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col px-3 py-3'>
|
||||
{fields.map((field, i) => (
|
||||
<div key={field.label}>
|
||||
{i > 0 && <FieldDivider />}
|
||||
<InspectorFieldRow field={field} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasTools && (
|
||||
<div>
|
||||
{fields.length > 0 && <FieldDivider />}
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<Label className='pl-0.5'>Tools</Label>
|
||||
<div className='flex flex-wrap gap-[6px]'>
|
||||
{tools?.map((tool) => {
|
||||
const TIcon = resolveIcon(tool.type)
|
||||
return (
|
||||
<ChipTag key={tool.type} variant='gray'>
|
||||
<span
|
||||
className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{TIcon && <TIcon className='size-[9px] text-white' />}
|
||||
</span>
|
||||
{tool.name}
|
||||
</ChipTag>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { domAnimation, LazyMotion } from 'framer-motion'
|
||||
import ReactFlow, { type NodeTypes, ReactFlowProvider } from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
|
||||
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
|
||||
import { toReactFlowElements } from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
/** The hero mounts the same node type the canvas uses, so it can never drift. */
|
||||
const NODE_TYPES: NodeTypes = { previewBlock: DocsBlockNode }
|
||||
const PRO_OPTIONS = { hideAttribution: true }
|
||||
/** `maxZoom` mirrors the previous hand-rolled hero's 1.3 scale. */
|
||||
const FIT_VIEW_OPTIONS = { padding: 0.2, maxZoom: 1.3 } as const
|
||||
|
||||
interface BlockPreviewProps {
|
||||
/** Block key from {@link BLOCK_DISPLAY_WORKFLOWS} (e.g. `agent`, `condition`, `webhook_trigger`). */
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single block exactly as it appears on the builder canvas, drawn by the
|
||||
* shared {@link WorkflowBlockView} (via `DocsBlockNode`) through the same ReactFlow
|
||||
* machinery as the multi-block diagrams — never a parallel hand-rolled card. Static
|
||||
* and non-interactive (no pan/zoom), centered in a bordered container. Use as the hero
|
||||
* on a block reference page: `<BlockPreview type="agent" />`. Edit the source data in
|
||||
* `block-display-workflows.ts`.
|
||||
*/
|
||||
export function BlockPreview({ type }: BlockPreviewProps) {
|
||||
const workflow = BLOCK_DISPLAY_WORKFLOWS[type]
|
||||
|
||||
const elements = useMemo(() => (workflow ? toReactFlowElements(workflow) : null), [workflow])
|
||||
|
||||
if (!workflow || !elements) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='not-prose my-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
|
||||
style={{ height: 400 }}
|
||||
>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={elements.nodes}
|
||||
edges={elements.edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
proOptions={PRO_OPTIONS}
|
||||
fitView
|
||||
fitViewOptions={FIT_VIEW_OPTIONS}
|
||||
minZoom={0.2}
|
||||
maxZoom={1.3}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnDoubleClick={false}
|
||||
zoomOnPinch={false}
|
||||
panOnDrag={false}
|
||||
panOnScroll={false}
|
||||
preventScrolling={false}
|
||||
className='h-full w-full'
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</LazyMotion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentType, memo } from 'react'
|
||||
import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer'
|
||||
import { m } from 'framer-motion'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import { resolveIcon } from '@/components/workflow-preview/block-icons'
|
||||
import {
|
||||
BLOCK_STAGGER,
|
||||
EASE_OUT,
|
||||
type PreviewTool,
|
||||
} from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
/** Renders the colored square with no glyph when a block type has no registered icon. */
|
||||
const EMPTY_ICON: ComponentType<{ className?: string }> = () => null
|
||||
|
||||
const RING_STYLES = 'ring-[1.75px] ring-[var(--brand-secondary)]'
|
||||
|
||||
interface DocsBlockData {
|
||||
name: string
|
||||
blockType: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
branches?: Array<{ id: string; label: string; value?: string }>
|
||||
tools?: PreviewTool[]
|
||||
hideTargetHandle?: boolean
|
||||
index?: number
|
||||
animate?: boolean
|
||||
isHighlighted?: boolean
|
||||
isDimmed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs adapter for workflow block nodes: maps the static preview data to the
|
||||
* shared {@link WorkflowBlockView}'s props. Carries no stores, hooks, or
|
||||
* queries — it only reshapes data into View props and wraps the result in the
|
||||
* dim/stagger motion used by the rest of the diagram (the parent
|
||||
* `WorkflowPreview` provides the `LazyMotion` feature set). The block's ring is
|
||||
* driven by `hasRing`/`ringStyles` inside the View.
|
||||
*/
|
||||
export const DocsBlockNode = memo(function DocsBlockNode({ id, data }: NodeProps<DocsBlockData>) {
|
||||
const {
|
||||
name,
|
||||
blockType,
|
||||
bgColor,
|
||||
rows: dataRows,
|
||||
branches,
|
||||
tools,
|
||||
hideTargetHandle = false,
|
||||
index = 0,
|
||||
animate = false,
|
||||
isHighlighted = false,
|
||||
isDimmed = false,
|
||||
} = data
|
||||
|
||||
/** The View gates router handle topology on `type === 'router_v2'`. */
|
||||
const type = blockType === 'router' ? 'router_v2' : blockType
|
||||
|
||||
const Icon = resolveIcon(blockType) ?? EMPTY_ICON
|
||||
const delay = animate ? index * BLOCK_STAGGER : 0
|
||||
|
||||
const hasBranches = Boolean(branches && branches.length > 0)
|
||||
const hasTools = Boolean(tools && tools.length > 0)
|
||||
|
||||
/** The View renders the default target/source/error handles (and the error row) for non-trigger blocks; mirror that gate. */
|
||||
const shouldShowDefaultHandles = !hideTargetHandle
|
||||
const hasContentBelowHeader =
|
||||
dataRows.length > 0 || hasBranches || hasTools || shouldShowDefaultHandles
|
||||
|
||||
/**
|
||||
* Strip the app's `condition-`/`router-` handle prefixes — the View
|
||||
* regenerates them, so passing them through would double-prefix the handle id.
|
||||
* Branch + router-context values render through the editor's `getDisplayValue`,
|
||||
* which shows `-` for a blank value (e.g. an `else` branch); mirror that.
|
||||
*/
|
||||
const conditionRows =
|
||||
type === 'condition'
|
||||
? (branches ?? []).map((branch) => ({
|
||||
id: branch.id.replace(/^condition-/, ''),
|
||||
title: branch.label,
|
||||
value: branch.value || '-',
|
||||
}))
|
||||
: []
|
||||
const routerRows =
|
||||
type === 'router_v2'
|
||||
? (branches ?? []).map((branch) => ({
|
||||
id: branch.id.replace(/^router-/, ''),
|
||||
value: branch.value || '-',
|
||||
}))
|
||||
: []
|
||||
/** The View renders the router's leading Context row from this prop, not `rows`. */
|
||||
const routerContextValue =
|
||||
type === 'router_v2' ? dataRows.find((row) => row.title === 'Context')?.value || '-' : undefined
|
||||
|
||||
/**
|
||||
* Non-branch content only — the View renders condition/router/error rows from
|
||||
* the conditionRows/routerRows it receives, so their order stays locked to its
|
||||
* handle geometry in one place.
|
||||
*/
|
||||
const rows =
|
||||
type === 'condition' || type === 'router_v2' ? null : (
|
||||
<>
|
||||
{dataRows.map((row) => (
|
||||
<SubBlockRowView key={row.title} title={row.title} displayValue={row.value} />
|
||||
))}
|
||||
{hasTools && (
|
||||
<SubBlockRowView
|
||||
title='Tools'
|
||||
displayValue={tools?.map((tool) => tool.name).join(', ')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className='relative transition-opacity duration-300'
|
||||
style={{ opacity: isDimmed ? 0.35 : 1 }}
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: isDimmed ? 0.35 : 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<WorkflowBlockView
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
isEnabled
|
||||
isLocked={false}
|
||||
hasRing={Boolean(isHighlighted)}
|
||||
ringStyles={RING_STYLES}
|
||||
Icon={Icon}
|
||||
iconBgColor={bgColor}
|
||||
horizontalHandles
|
||||
shouldShowDefaultHandles={shouldShowDefaultHandles}
|
||||
hasContentBelowHeader={hasContentBelowHeader}
|
||||
conditionRows={conditionRows}
|
||||
routerRows={routerRows}
|
||||
routerContextValue={routerContextValue}
|
||||
wouldCreateConnectionCycle={() => false}
|
||||
onSelect={() => {}}
|
||||
rows={rows}
|
||||
/>
|
||||
</m.div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { type SubflowNodeData, SubflowNodeView } from '@sim/workflow-renderer'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
|
||||
interface DocsContainerData {
|
||||
name: string
|
||||
blockType: string
|
||||
size?: { width: number; height: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs adapter for loop/parallel container blocks: maps the static preview data
|
||||
* to {@link SubflowNodeView}'s read-only `isPreview` shape. Carries no stores,
|
||||
* hooks, or queries — it only reshapes data into View props.
|
||||
*/
|
||||
export const DocsContainerNode = memo(function DocsContainerNode({
|
||||
id,
|
||||
data,
|
||||
}: NodeProps<DocsContainerData>) {
|
||||
const subflowData: SubflowNodeData = {
|
||||
kind: data.blockType === 'parallel' ? 'parallel' : 'loop',
|
||||
name: data.name,
|
||||
width: data.size?.width,
|
||||
height: data.size?.height,
|
||||
isPreview: true,
|
||||
}
|
||||
|
||||
return (
|
||||
<SubflowNodeView
|
||||
id={id}
|
||||
data={subflowData}
|
||||
isEnabled
|
||||
isLocked={false}
|
||||
isFocused={false}
|
||||
nestingLevel={0}
|
||||
canEditWorkflow={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Block references `<block.field>` and environment variables `{{VAR}}`. */
|
||||
const REFERENCE_PATTERN = /(<[^<>]+>|\{\{[^{}]+\}\})/g
|
||||
|
||||
/**
|
||||
* Highlights `<...>` block references and `{{...}}` environment variables in
|
||||
* brand-secondary, mirroring the editor's `formatDisplayText`. Read-only and
|
||||
* static — no validation or tag interactivity, since docs has no workflow state.
|
||||
*/
|
||||
export function formatReferences(text: string): ReactNode[] {
|
||||
if (!text) return []
|
||||
return text.split(REFERENCE_PATTERN).map((part, index) => {
|
||||
if (!part) return null
|
||||
const isReference =
|
||||
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
|
||||
return isReference ? (
|
||||
<span key={index} className='text-[var(--brand-secondary)]'>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export { BlockPreview } from '@/components/workflow-preview/block-preview'
|
||||
export {
|
||||
API_FETCH_WORKFLOW,
|
||||
BUILD_AGENT_WORKFLOW,
|
||||
CLASSIFY_REPLY_WORKFLOW,
|
||||
CLASSIFY_WORKFLOW,
|
||||
COMBINATION_WORKFLOW,
|
||||
CONCURRENCY_WORKFLOW,
|
||||
CONDITION_MODERATE_WORKFLOW,
|
||||
CONDITION_ONBOARD_WORKFLOW,
|
||||
CONDITION_ROUTE_WORKFLOW,
|
||||
CREDENTIAL_ROUTE_WORKFLOW,
|
||||
CREDENTIAL_SHARE_WORKFLOW,
|
||||
ERROR_PATH_WORKFLOW,
|
||||
EVALUATOR_GATE_WORKFLOW,
|
||||
FILE_SUMMARY_WORKFLOW,
|
||||
FUNCTION_RESHAPE_WORKFLOW,
|
||||
FUNCTION_VALIDATE_WORKFLOW,
|
||||
GUARDRAILS_HALLUCINATION_WORKFLOW,
|
||||
GUARDRAILS_JSON_WORKFLOW,
|
||||
GUARDRAILS_PII_WORKFLOW,
|
||||
HITL_APPROVAL_WORKFLOW,
|
||||
HITL_MULTISTAGE_WORKFLOW,
|
||||
HITL_VALIDATE_WORKFLOW,
|
||||
LEAD_SCORER_WORKFLOW,
|
||||
LOOP_WORKFLOW,
|
||||
PARALLEL_WORKFLOW,
|
||||
RESPONSE_API_WORKFLOW,
|
||||
RESPONSE_ERROR_WORKFLOW,
|
||||
RESPONSE_WEBHOOK_WORKFLOW,
|
||||
ROUTER_CLASSIFY_WORKFLOW,
|
||||
ROUTER_LEAD_WORKFLOW,
|
||||
ROUTER_TRIAGE_WORKFLOW,
|
||||
ROUTING_WORKFLOW,
|
||||
SUPPORT_KB_WORKFLOW,
|
||||
TABLE_ENRICH_WORKFLOW,
|
||||
TABLE_ROUNDTRIP_WORKFLOW,
|
||||
VARIABLES_CONFIG_WORKFLOW,
|
||||
VARIABLES_RETRY_WORKFLOW,
|
||||
WAIT_FOLLOWUP_WORKFLOW,
|
||||
WAIT_RATELIMIT_WORKFLOW,
|
||||
WEBHOOK_NOTIFY_WORKFLOW,
|
||||
WEBHOOK_TRIGGER_WORKFLOW,
|
||||
WORKFLOW_CALL_WORKFLOW,
|
||||
} from '@/components/workflow-preview/examples'
|
||||
export { OutputBundle } from '@/components/workflow-preview/output-bundle'
|
||||
export type {
|
||||
PreviewBlock,
|
||||
PreviewTool,
|
||||
PreviewWorkflow,
|
||||
} from '@/components/workflow-preview/workflow-data'
|
||||
export { WorkflowPreview } from '@/components/workflow-preview/workflow-preview'
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@sim/emcn'
|
||||
import { ChevronDown, Clipboard, Download, Search } from 'lucide-react'
|
||||
import { resolveIcon } from '@/components/workflow-preview/block-icons'
|
||||
|
||||
type ValueType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
|
||||
|
||||
/** Output value type → emcn Badge color variant. */
|
||||
const TYPE_VARIANT = {
|
||||
string: 'green',
|
||||
number: 'blue',
|
||||
boolean: 'orange',
|
||||
array: 'purple',
|
||||
object: 'gray',
|
||||
null: 'gray',
|
||||
} as const
|
||||
|
||||
interface OutputNode {
|
||||
key: string
|
||||
type?: ValueType
|
||||
/** Primitive value shown beneath the key when expanded. */
|
||||
value?: string
|
||||
/** Nested fields for object/array nodes. */
|
||||
children?: OutputNode[]
|
||||
/** Collapse the node (chevron points right, nothing rendered beneath). */
|
||||
expanded?: boolean
|
||||
/** Emphasize this row as the one being read by the tag below. */
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
interface LogRow {
|
||||
name: string
|
||||
type?: string
|
||||
color?: string
|
||||
duration?: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
interface OutputBundleProps {
|
||||
/** The block's name (unique within the workflow), e.g. "classify". */
|
||||
blockName: string
|
||||
blockType?: string
|
||||
blockColor?: string
|
||||
/** Duration shown on the selected log row. */
|
||||
duration?: string
|
||||
/** Override the Logs column; defaults to Start + this block (selected). */
|
||||
logs?: LogRow[]
|
||||
values: OutputNode[]
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: ValueType }) {
|
||||
return (
|
||||
<Badge variant={TYPE_VARIANT[type]} size='sm'>
|
||||
{type}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeNode({ node, depth = 0 }: { node: OutputNode; depth?: number }) {
|
||||
const type = node.type ?? 'string'
|
||||
const expanded = node.expanded ?? Boolean(node.children || node.value !== undefined)
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<div className='flex min-h-[26px] items-center gap-2 rounded-[6px] px-1'>
|
||||
<span
|
||||
className='text-[13px]'
|
||||
style={{ color: node.highlight ? 'var(--brand-secondary)' : 'var(--text-primary)' }}
|
||||
>
|
||||
{node.key}
|
||||
</span>
|
||||
<TypeBadge type={type} />
|
||||
<ChevronDown
|
||||
className='h-[7px] w-[9px] flex-shrink-0 text-[var(--text-muted)]'
|
||||
style={expanded ? undefined : { transform: 'rotate(-90deg)' }}
|
||||
/>
|
||||
</div>
|
||||
{expanded && (node.children || node.value !== undefined) && (
|
||||
<div className='mt-0.5 ml-[5px] flex min-w-0 flex-col gap-0.5 border-[var(--divider)] border-l pl-[10px]'>
|
||||
{node.children
|
||||
? node.children.map((child) => (
|
||||
<TreeNode key={child.key} node={child} depth={depth + 1} />
|
||||
))
|
||||
: node.value !== undefined && (
|
||||
<div className='py-0.5 text-[13px] text-[var(--text-secondary)]'>{node.value}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A miniature of the app's run inspector — the Logs list beside the Output
|
||||
* panel's typed tree — teaching what a block's output is: named, typed values
|
||||
* remembered under the block's name, read with a `<blockName.key>` tag.
|
||||
*/
|
||||
export function OutputBundle({
|
||||
blockName,
|
||||
blockType = 'agent',
|
||||
blockColor = '#33C482',
|
||||
duration = '1.2s',
|
||||
logs,
|
||||
values,
|
||||
}: OutputBundleProps) {
|
||||
const logRows: LogRow[] = logs ?? [
|
||||
{ name: 'Start', type: 'start_trigger', color: '#2FB3FF', duration: '9ms' },
|
||||
{ name: blockName, type: blockType, color: blockColor, duration, selected: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='not-prose my-6 flex w-full max-w-[640px] flex-col gap-3'>
|
||||
<div className='flex overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<div className='flex w-[210px] flex-shrink-0 flex-col border-[var(--border)] border-r px-2 py-2'>
|
||||
<div className='px-2 pb-2 text-[12px] text-[var(--text-muted)]'>Logs</div>
|
||||
{logRows.map((row) => {
|
||||
const Icon = row.type ? resolveIcon(row.type) : null
|
||||
return (
|
||||
<div
|
||||
key={row.name}
|
||||
className='flex h-[30px] items-center gap-2 rounded-[6px] px-2'
|
||||
style={row.selected ? { background: 'var(--surface-active)' } : undefined}
|
||||
>
|
||||
<div
|
||||
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-[5px]'
|
||||
style={{ background: row.color ?? 'var(--border-1)' }}
|
||||
>
|
||||
{Icon && <Icon className='size-[10px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate text-[13px] text-[var(--text-primary)]'>{row.name}</span>
|
||||
{row.duration && (
|
||||
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>
|
||||
{row.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 flex-1 flex-col px-3 py-2'>
|
||||
<div className='flex items-center gap-3 pb-2'>
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>Output</span>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>Input</span>
|
||||
<span className='ml-auto flex items-center gap-2 text-[var(--text-subtle)]'>
|
||||
<Search className='size-[12px]' />
|
||||
<Clipboard className='size-[12px]' />
|
||||
<Download className='size-[12px]' />
|
||||
<ChevronDown className='size-[12px]' />
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
{values.map((node) => (
|
||||
<TreeNode key={node.key} node={node} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { type Edge, type Node, Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Tool entry displayed as a chip on a block (e.g. an Agent's attached tools).
|
||||
*/
|
||||
export interface PreviewTool {
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A single block in a preview workflow. Presentational shape — authored by hand
|
||||
* for docs diagrams, not the app's full serialized block state.
|
||||
*/
|
||||
export interface PreviewBlock {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
/**
|
||||
* Branch rows, each with its own right-edge source handle whose id is the
|
||||
* branch id. Author ids in the app's own handle scheme so edges match the
|
||||
* real workflow representation verbatim: `condition-<id>` on Condition
|
||||
* blocks, `router-<routeId>` on Routers.
|
||||
*/
|
||||
branches?: Array<{ id: string; label: string; value?: string }>
|
||||
tools?: PreviewTool[]
|
||||
position: { x: number; y: number }
|
||||
hideTargetHandle?: boolean
|
||||
/** When set, the block renders as a Loop/Parallel container sized to hold its children. */
|
||||
size?: { width: number; height: number }
|
||||
/** Id of the container block this block sits inside. Its position is relative to the container. */
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A workflow rendered as a read-only, app-styled diagram in the docs.
|
||||
*/
|
||||
export interface PreviewWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
blocks: PreviewBlock[]
|
||||
edges: Array<{ id: string; source: string; target: string; sourceHandle?: string }>
|
||||
}
|
||||
|
||||
export const BLOCK_STAGGER = 0.12
|
||||
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||
|
||||
const EDGE_STYLE = { stroke: 'var(--workflow-edge)', strokeWidth: 2 } as const
|
||||
const EDGE_STYLE_HIGHLIGHT = { stroke: 'var(--brand-secondary)', strokeWidth: 2.5 } as const
|
||||
/** Edges leaving a block's error port render red, matching the editor. */
|
||||
const EDGE_STYLE_ERROR = { stroke: 'var(--text-error)', strokeWidth: 2 } as const
|
||||
|
||||
/** Optional emphasis: light one block or one edge and dim everything else. */
|
||||
export interface HighlightOptions {
|
||||
highlightBlock?: string
|
||||
highlightEdge?: string
|
||||
/** Ring one block (selection) without dimming the rest. */
|
||||
selectedBlock?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a {@link PreviewWorkflow} to React Flow nodes and edges.
|
||||
*
|
||||
* @param workflow - The workflow definition
|
||||
* @param animate - When true, node/edge data carries stagger metadata
|
||||
* @param highlight - Optional block/edge to emphasize (dims the rest)
|
||||
*/
|
||||
export function toReactFlowElements(
|
||||
workflow: PreviewWorkflow,
|
||||
animate = false,
|
||||
highlight: HighlightOptions = {}
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const { highlightBlock, highlightEdge, selectedBlock } = highlight
|
||||
const hasHighlight = Boolean(highlightBlock || highlightEdge)
|
||||
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
|
||||
|
||||
const blocksById = new Map(workflow.blocks.map((b) => [b.id, b]))
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => {
|
||||
const isContainer = Boolean(block.size)
|
||||
// Nested blocks are authored relative to their container; render them at
|
||||
// absolute coordinates (not React Flow parentNode children) so the edges
|
||||
// between a container and its nested blocks render reliably and on top.
|
||||
const parent = block.parentId ? blocksById.get(block.parentId) : undefined
|
||||
const position = parent
|
||||
? { x: parent.position.x + block.position.x, y: parent.position.y + block.position.y }
|
||||
: block.position
|
||||
return {
|
||||
id: block.id,
|
||||
type: isContainer ? 'previewContainer' : 'previewBlock',
|
||||
position,
|
||||
zIndex: isContainer ? 0 : 1,
|
||||
...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}),
|
||||
data: {
|
||||
name: block.name,
|
||||
blockType: block.type,
|
||||
bgColor: block.bgColor,
|
||||
rows: block.rows,
|
||||
branches: block.branches,
|
||||
tools: block.tools,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
size: block.size,
|
||||
index,
|
||||
animate,
|
||||
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
|
||||
isDimmed: hasHighlight && highlightBlock !== block.id,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}
|
||||
})
|
||||
|
||||
const edges: Edge[] = workflow.edges.map((e) => {
|
||||
const sourceIndex = blockIndexMap.get(e.source) ?? 0
|
||||
const isEdgeHighlight = highlightEdge === e.id
|
||||
const dimmed = hasHighlight && !isEdgeHighlight
|
||||
const isErrorEdge = e.sourceHandle === 'error'
|
||||
// Subflow containers expose a right-edge output handle (`loop-end-source` /
|
||||
// `parallel-end-source`) and a left-edge input handle with no id; regular
|
||||
// blocks use `source` / `target`. Resolve each end to the block's real handle
|
||||
// so edges into and out of Loop/Parallel containers still connect.
|
||||
const sourceBlock = blocksById.get(e.source)
|
||||
const targetBlock = blocksById.get(e.target)
|
||||
const sourceHandle =
|
||||
e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source')
|
||||
const targetHandle = targetBlock?.size ? undefined : 'target'
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: 'previewEdge',
|
||||
animated: false,
|
||||
style: {
|
||||
...(isEdgeHighlight ? EDGE_STYLE_HIGHLIGHT : isErrorEdge ? EDGE_STYLE_ERROR : EDGE_STYLE),
|
||||
opacity: dimmed ? 0.35 : 1,
|
||||
},
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { Maximize2, X } from 'lucide-react'
|
||||
import ReactFlow, {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
type EdgeTypes,
|
||||
getSmoothStepPath,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
ReactFlowProvider,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
|
||||
import { BlockInspector } from '@/components/workflow-preview/block-inspector'
|
||||
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
|
||||
import { DocsContainerNode } from '@/components/workflow-preview/docs-container-node'
|
||||
import {
|
||||
EASE_OUT,
|
||||
type PreviewBlock,
|
||||
type PreviewWorkflow,
|
||||
toReactFlowElements,
|
||||
} from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
interface WorkflowPreviewProps {
|
||||
workflow: PreviewWorkflow
|
||||
/** Canvas height in px. Default 260. */
|
||||
height?: number
|
||||
animate?: boolean
|
||||
/** Emphasize one block by id, dimming the rest. */
|
||||
highlightBlock?: string
|
||||
/** Emphasize one edge by id, dimming the rest. */
|
||||
highlightEdge?: string
|
||||
}
|
||||
|
||||
/** Smooth-step edge, matching the app's connection styling. */
|
||||
function PreviewEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: 30,
|
||||
})
|
||||
|
||||
if (data?.animate) {
|
||||
return (
|
||||
<m.path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
style={{ ...style, fill: 'none' }}
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{
|
||||
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
|
||||
opacity: { duration: 0.15, delay: data.delay ?? 0 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
style={{ ...style, fill: 'none' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const NODE_TYPES: NodeTypes = {
|
||||
previewBlock: DocsBlockNode,
|
||||
previewContainer: DocsContainerNode,
|
||||
}
|
||||
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
|
||||
const PRO_OPTIONS = { hideAttribution: true }
|
||||
const FIT_VIEW_OPTIONS = { padding: 0.25, maxZoom: 1 } as const
|
||||
const LIGHTBOX_FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1.4 } as const
|
||||
|
||||
/** Field titles rendered as multiline text in the inspector. */
|
||||
const TEXTAREA_TITLES = new Set(['Messages', 'Prompt', 'Code', 'Data', 'Body', 'Display'])
|
||||
/** Field titles rendered as dropdowns in the inspector. */
|
||||
const SELECT_TITLES = new Set([
|
||||
'Model',
|
||||
'Operation',
|
||||
'Method',
|
||||
'Unit',
|
||||
'Event type',
|
||||
'Validation',
|
||||
'Account',
|
||||
'Table',
|
||||
'Knowledge Base',
|
||||
'Language',
|
||||
'Workflow',
|
||||
'Format',
|
||||
])
|
||||
|
||||
function inspectorFieldsFor(block: PreviewBlock) {
|
||||
// Show the block type's full field list (from the reference data) with this
|
||||
// block's example values overlaid, so the inspector reads like the editor's
|
||||
// panel — every field — not just the summary rows shown on the canvas node.
|
||||
// Apply the template only when the block authored rows that are actually a
|
||||
// subset of it; otherwise the type string is ambiguous (a `table` action vs
|
||||
// the table trigger, a `webhook` trigger vs the webhook action) and the wrong
|
||||
// template would win, so the block's own authored rows are the source of
|
||||
// truth. A block with no rows (e.g. a router defined only by its branches)
|
||||
// keeps its empty set rather than inheriting the template's invented defaults.
|
||||
const exampleByTitle = new Map(block.rows.map((row) => [row.title, row.value]))
|
||||
const template = BLOCK_DISPLAY_WORKFLOWS[block.type]?.blocks[0]?.rows
|
||||
const fullRows =
|
||||
template &&
|
||||
block.rows.length > 0 &&
|
||||
block.rows.every((row) => template.some((field) => field.title === row.title))
|
||||
? template
|
||||
: block.rows
|
||||
const rowFields = fullRows.map((row) => {
|
||||
const value = exampleByTitle.get(row.title) ?? row.value
|
||||
return {
|
||||
label: row.title,
|
||||
kind:
|
||||
TEXTAREA_TITLES.has(row.title) || value.length > 40
|
||||
? ('textarea' as const)
|
||||
: SELECT_TITLES.has(row.title)
|
||||
? ('select' as const)
|
||||
: ('input' as const),
|
||||
value,
|
||||
}
|
||||
})
|
||||
const branchFields = (block.branches ?? []).map((branch) => ({
|
||||
label: branch.label,
|
||||
kind: 'code' as const,
|
||||
// Match the canvas + the editor's getDisplayValue: a blank value reads as '-'.
|
||||
value: branch.value || '-',
|
||||
}))
|
||||
return [...rowFields, ...branchFields]
|
||||
}
|
||||
|
||||
function PreviewFlow({
|
||||
workflow,
|
||||
animate = false,
|
||||
highlightBlock,
|
||||
highlightEdge,
|
||||
selectedBlock,
|
||||
interactive = false,
|
||||
onNodeClick,
|
||||
onPaneClick,
|
||||
}: WorkflowPreviewProps & {
|
||||
selectedBlock?: string
|
||||
interactive?: boolean
|
||||
onNodeClick?: (blockId: string) => void
|
||||
onPaneClick?: () => void
|
||||
}) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => toReactFlowElements(workflow, animate, { highlightBlock, highlightEdge, selectedBlock }),
|
||||
[workflow, animate, highlightBlock, highlightEdge, selectedBlock]
|
||||
)
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(initialNodes)
|
||||
const [edges, setEdges] = useState<Edge[]>(initialEdges)
|
||||
|
||||
/**
|
||||
* Apply data changes (highlight/selection) without discarding positions the
|
||||
* viewer has dragged — only a different workflow should relayout the canvas.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setNodes((prev) => {
|
||||
const positions = new Map(prev.map((node) => [node.id, node.position]))
|
||||
return initialNodes.map((node) => {
|
||||
const position = positions.get(node.id)
|
||||
return position ? { ...node, position } : node
|
||||
})
|
||||
})
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges])
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
[]
|
||||
)
|
||||
const onEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick ? (_, node) => onNodeClick(node.id) : undefined}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{ type: 'previewEdge' }}
|
||||
elementsSelectable={false}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
zoomOnScroll={interactive}
|
||||
zoomOnDoubleClick={interactive}
|
||||
panOnScroll={false}
|
||||
zoomOnPinch
|
||||
panOnDrag
|
||||
preventScrolling={interactive}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={PRO_OPTIONS}
|
||||
minZoom={0.1}
|
||||
fitView
|
||||
fitViewOptions={interactive ? LIGHTBOX_FIT_VIEW_OPTIONS : FIT_VIEW_OPTIONS}
|
||||
className='h-full w-full'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only, app-styled workflow diagram for docs pages. Renders a
|
||||
* {@link PreviewWorkflow} with ReactFlow — draggable, non-editable, no app
|
||||
* runtime. Clicking a block (or the expand control) opens a full-screen
|
||||
* lightbox with zoom and pan, plus a read-only inspector panel showing the
|
||||
* selected block's full configuration — canvas rows truncate, the inspector
|
||||
* doesn't.
|
||||
*
|
||||
* @example
|
||||
* <WorkflowPreview workflow={CLASSIFY_WORKFLOW} />
|
||||
*/
|
||||
export function WorkflowPreview({
|
||||
workflow,
|
||||
height = 340,
|
||||
animate = false,
|
||||
highlightBlock,
|
||||
highlightEdge,
|
||||
}: WorkflowPreviewProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setExpanded(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.body.classList.add('wp-lightbox-open')
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = previousOverflow
|
||||
document.body.classList.remove('wp-lightbox-open')
|
||||
}
|
||||
}, [expanded])
|
||||
|
||||
const selectedBlock = selectedId
|
||||
? (workflow.blocks.find((b) => b.id === selectedId) ?? null)
|
||||
: null
|
||||
|
||||
const openWith = (blockId: string | null) => {
|
||||
setSelectedId(blockId)
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div
|
||||
className='not-prose group relative my-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
|
||||
style={{ height }}
|
||||
>
|
||||
<ReactFlowProvider key={`${workflow.id}-${highlightBlock ?? ''}-${highlightEdge ?? ''}`}>
|
||||
<PreviewFlow
|
||||
workflow={workflow}
|
||||
animate={animate}
|
||||
highlightBlock={highlightBlock}
|
||||
highlightEdge={highlightEdge}
|
||||
onNodeClick={(id) => openWith(id)}
|
||||
onPaneClick={() => openWith(null)}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Expand workflow preview'
|
||||
onClick={() => openWith(null)}
|
||||
className='absolute top-2 right-2 z-10 flex size-[28px] items-center justify-center rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-muted)] opacity-0 transition-opacity duration-150 hover:text-[var(--text-primary)] group-hover:opacity-100'
|
||||
>
|
||||
<Maximize2 className='size-[13px]' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm'
|
||||
onClick={() => setExpanded(false)}
|
||||
onKeyDown={() => {}}
|
||||
role='presentation'
|
||||
>
|
||||
<div
|
||||
className='relative flex h-[86vh] w-[92vw] overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={() => {}}
|
||||
role='presentation'
|
||||
>
|
||||
<div className='relative min-w-0 flex-1'>
|
||||
<div className='pointer-events-none absolute top-0 right-0 left-0 z-10 flex items-center justify-between px-4 py-3'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>{workflow.name}</span>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Close'
|
||||
onClick={() => setExpanded(false)}
|
||||
className='pointer-events-auto flex size-[28px] items-center justify-center rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='size-[14px]' />
|
||||
</button>
|
||||
</div>
|
||||
<ReactFlowProvider key={`${workflow.id}-lightbox`}>
|
||||
<PreviewFlow
|
||||
workflow={workflow}
|
||||
highlightBlock={highlightBlock}
|
||||
highlightEdge={highlightEdge}
|
||||
selectedBlock={selectedId ?? undefined}
|
||||
interactive
|
||||
onNodeClick={(id) => setSelectedId(id)}
|
||||
onPaneClick={() => setSelectedId(null)}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
|
||||
<div className='w-[340px] flex-shrink-0 border-[var(--border)] border-l'>
|
||||
{selectedBlock ? (
|
||||
<BlockInspector
|
||||
embedded
|
||||
name={selectedBlock.name}
|
||||
type={selectedBlock.type}
|
||||
color={selectedBlock.bgColor}
|
||||
fields={inspectorFieldsFor(selectedBlock)}
|
||||
tools={selectedBlock.tools}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center px-6 text-center text-[13px] text-[var(--text-muted)]'>
|
||||
Select a block to see its full configuration
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user