diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..d9db03e1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2025 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 691f5ccfd..8813f3260 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ -# V8 Workflow Builder +# Workflow Builder Template -A powerful visual workflow builder with real integrations and code generation capabilities. +A template for building your own workflow automation platform. Built on top of Workflow DevKit, this template provides a complete visual workflow builder with real integrations and code generation capabilities. -## Features +## What's Included - **Visual Workflow Builder** - Drag-and-drop interface powered by React Flow -- **Real Integrations** - Connect to Resend (emails), Linear (tickets), PostgreSQL, and external APIs +- **Workflow DevKit Integration** - Built on top of Workflow DevKit for powerful execution capabilities +- **Real Integrations** - Connect to Resend (emails), Linear (tickets), Slack, PostgreSQL, and external APIs - **Code Generation** - Convert workflows to executable TypeScript with `"use workflow"` directive - **Execution Tracking** - Monitor workflow runs with detailed logs - **Authentication** - Secure user authentication with Better Auth -- **AI-Powered** - Generate workflows from natural language descriptions +- **AI-Powered** - Generate workflows from natural language descriptions using OpenAI +- **Database** - PostgreSQL with Drizzle ORM for type-safe database access +- **Modern UI** - Beautiful shadcn/ui components with dark mode support ## Getting Started @@ -25,25 +28,28 @@ Create a `.env.local` file with the following: ```env # Database -DATABASE_URL=postgresql://user:password@localhost:5432/v8_workflow +DATABASE_URL=postgresql://user:password@localhost:5432/workflow_builder # Better Auth BETTER_AUTH_SECRET=your-secret-key BETTER_AUTH_URL=http://localhost:3000 -# Vercel (App-level - all projects created with workflow-builder- prefix) +# OpenAI (for AI workflow generation) +OPENAI_API_KEY=your-openai-api-key + +# Vercel (for deploying workflows) VERCEL_API_TOKEN=your-vercel-api-token VERCEL_TEAM_ID=team_xxxxxxxxxxxxx # Optional, for team projects -# Resend (Email) +# Resend (for sending emails) RESEND_API_KEY=your-resend-api-key RESEND_FROM_EMAIL=onboarding@yourdomain.com -# Linear (Tickets) +# Linear (for creating tickets) LINEAR_API_KEY=your-linear-api-key -# OpenAI (for AI workflow generation) -OPENAI_API_KEY=your-openai-api-key +# Slack (for sending messages) +SLACK_BOT_TOKEN=xoxb-your-slack-bot-token ``` ### Installation @@ -237,6 +243,32 @@ await callApi({ }); ``` +## Tech Stack + +- **Framework**: Next.js 16 with React 19 +- **Workflow Engine**: Workflow DevKit +- **UI**: shadcn/ui with Tailwind CSS +- **State Management**: Jotai +- **Database**: PostgreSQL with Drizzle ORM +- **Authentication**: Better Auth +- **Code Editor**: Monaco Editor +- **Workflow Canvas**: React Flow +- **AI**: OpenAI GPT-5 +- **Type Checking**: TypeScript +- **Code Quality**: Ultracite (formatter + linter) + +## About Workflow DevKit + +This template is built on top of Workflow DevKit, a powerful workflow execution engine that enables: + +- Native TypeScript workflow definitions with `"use workflow"` directive +- Type-safe workflow execution +- Automatic code generation from visual workflows +- Built-in logging and error handling +- Serverless deployment support + +Learn more about Workflow DevKit at [workflow.dev](https://workflow.dev) + ## License -MIT +Apache 2.0 diff --git a/app/api/ai/generate/route.ts b/app/api/ai/generate/route.ts index b6a4620a3..a320560ac 100644 --- a/app/api/ai/generate/route.ts +++ b/app/api/ai/generate/route.ts @@ -2,63 +2,229 @@ import { streamText } from "ai"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; +// Simple type for operations +type Operation = { + op: + | "setName" + | "setDescription" + | "addNode" + | "addEdge" + | "removeNode" + | "removeEdge" + | "updateNode"; + name?: string; + description?: string; + node?: unknown; + edge?: unknown; + nodeId?: string; + edgeId?: string; + updates?: { + position?: { x: number; y: number }; + data?: unknown; + }; +}; + +function encodeMessage(encoder: TextEncoder, message: object): Uint8Array { + return encoder.encode(`${JSON.stringify(message)}\n`); +} + +function shouldSkipLine(line: string): boolean { + const trimmed = line.trim(); + return !trimmed || trimmed.startsWith("```"); +} + +function tryParseAndEnqueueOperation( + line: string, + encoder: TextEncoder, + controller: ReadableStreamDefaultController, + operationCount: number +): number { + const trimmed = line.trim(); + + if (shouldSkipLine(line)) { + return operationCount; + } + + try { + const operation = JSON.parse(trimmed) as Operation; + const newCount = operationCount + 1; + + console.log(`[API] Operation ${newCount}:`, operation.op); + + controller.enqueue( + encodeMessage(encoder, { + type: "operation", + operation, + }) + ); + + return newCount; + } catch { + console.warn("[API] Skipping invalid JSON line:", trimmed.substring(0, 50)); + return operationCount; + } +} + +function processBufferLines( + buffer: string, + encoder: TextEncoder, + controller: ReadableStreamDefaultController, + operationCount: number +): { remainingBuffer: string; newOperationCount: number } { + const lines = buffer.split("\n"); + const remainingBuffer = lines.pop() || ""; + let newOperationCount = operationCount; + + for (const line of lines) { + newOperationCount = tryParseAndEnqueueOperation( + line, + encoder, + controller, + newOperationCount + ); + } + + return { remainingBuffer, newOperationCount }; +} + +async function processOperationStream( + textStream: AsyncIterable, + encoder: TextEncoder, + controller: ReadableStreamDefaultController +): Promise { + let buffer = ""; + let operationCount = 0; + let chunkCount = 0; + + for await (const chunk of textStream) { + chunkCount += 1; + buffer += chunk; + + const result = processBufferLines( + buffer, + encoder, + controller, + operationCount + ); + buffer = result.remainingBuffer; + operationCount = result.newOperationCount; + } + + // Process any remaining buffer content + operationCount = tryParseAndEnqueueOperation( + buffer, + encoder, + controller, + operationCount + ); + + console.log( + `[API] Stream complete. Chunks: ${chunkCount}, Operations: ${operationCount}` + ); + + // Send completion + controller.enqueue( + encodeMessage(encoder, { + type: "complete", + }) + ); +} + const system = `You are a workflow automation expert. Generate a workflow based on the user's description. -Return a JSON object with this structure: +CRITICAL: Output your workflow as INDIVIDUAL OPERATIONS, one per line in JSONL format. +Each line must be a complete, separate JSON object. + +Operations you can output: +1. {"op": "setName", "name": "Workflow Name"} +2. {"op": "setDescription", "description": "Brief description"} +3. {"op": "addNode", "node": {COMPLETE_NODE_OBJECT}} +4. {"op": "addEdge", "edge": {COMPLETE_EDGE_OBJECT}} +5. {"op": "removeNode", "nodeId": "node-id-to-remove"} +6. {"op": "removeEdge", "edgeId": "edge-id-to-remove"} +7. {"op": "updateNode", "nodeId": "node-id", "updates": {"position": {"x": 100, "y": 200}}} + +IMPORTANT RULES: +- Every workflow must have EXACTLY ONE trigger node +- Output ONE operation per line +- Each line must be complete, valid JSON +- Start with setName and setDescription +- Then add nodes one at a time +- Finally add edges one at a time to CONNECT ALL NODES +- CRITICAL: Every node (except the last) MUST be connected to at least one other node +- To update node positions or properties, use updateNode operation +- NEVER output explanatory text - ONLY JSON operations +- Do NOT wrap in markdown code blocks +- Do NOT add explanatory text + +Node structure: { - "name": "Workflow Name", - "description": "Brief description", - "nodes": [ - { - "id": "unique-id", - "type": "trigger|action|condition|transform", - "position": { "x": number, "y": number }, - "data": { - "label": "Node Label", - "description": "Node description", - "type": "trigger|action|condition|transform", - "config": { /* type-specific config */ }, - "status": "idle" - } - } - ], - "edges": [ - { - "id": "edge-id", - "source": "source-node-id", - "target": "target-node-id", - "type": "default" - } - ] + "id": "unique-id", + "type": "trigger" or "action", + "position": {"x": number, "y": number}, + "data": { + "label": "Node Label", + "description": "Node description", + "type": "trigger" or "action", + "config": {...}, + "status": "idle" + } } -Node types and their configs: +Trigger types: +- Manual: {"triggerType": "Manual"} +- Webhook: {"triggerType": "Webhook", "webhookPath": "/webhooks/name", ...} +- Schedule: {"triggerType": "Schedule", "scheduleCron": "0 9 * * *", ...} -TRIGGER NODES: -- Manual: { triggerType: "Manual" } -- Webhook: { triggerType: "Webhook", webhookPath: "/webhooks/name", webhookMethod: "POST" } -- Schedule: { triggerType: "Schedule", scheduleCron: "0 9 * * *", scheduleTimezone: "America/New_York" } +Action types: +- Send Email: {"actionType": "Send Email", "emailTo": "user@example.com", "emailSubject": "Subject", "emailBody": "Body"} +- Send Slack Message: {"actionType": "Send Slack Message", "slackChannel": "#general", "slackMessage": "Message"} +- Create Ticket: {"actionType": "Create Ticket", "ticketTitle": "Title", "ticketDescription": "Description", "ticketPriority": "2"} +- Database Query: {"actionType": "Database Query", "dbQuery": "SELECT * FROM table", "dbTable": "table"} +- HTTP Request: {"actionType": "HTTP Request", "httpMethod": "POST", "endpoint": "https://api.example.com", "httpHeaders": "{}", "httpBody": "{}"} +- Generate Text: {"actionType": "Generate Text", "aiModel": "gpt-5", "aiFormat": "text", "aiPrompt": "Your prompt here"} +- Generate Image: {"actionType": "Generate Image", "imageModel": "openai/dall-e-3", "imagePrompt": "Image description"} +- Condition: {"actionType": "Condition", "condition": "{{@nodeId:Label.field}} === 'value'"} -ACTION NODES: -- Send Email: { actionType: "Send Email", emailTo: "user@example.com", emailSubject: "Subject", emailBody: "Body text" } -- Send Slack Message: { actionType: "Send Slack Message", slackChannel: "#general", slackMessage: "Message text" } -- Create Ticket: { actionType: "Create Ticket", ticketTitle: "Title", ticketDescription: "Description", ticketPriority: "2" } -- Database Query: { actionType: "Database Query", dbQuery: "SELECT * FROM users WHERE status = 'active'", dbTable: "users" } -- HTTP Request: { actionType: "HTTP Request", httpMethod: "POST", endpoint: "https://api.example.com/endpoint", httpHeaders: "{}", httpBody: "{}" } +CRITICAL ABOUT CONDITION NODES: +- Condition nodes evaluate a boolean expression +- When TRUE: ALL connected nodes execute +- When FALSE: ALL connected nodes are SKIPPED +- For if/else logic, CREATE MULTIPLE SEPARATE condition nodes (one per branch) +- NEVER connect multiple different outcome paths to a single condition node +- Each condition should check for ONE specific case -CONDITION NODES: -- { condition: "status === 'active'" } +Example: "if good send Slack, if bad create ticket" needs TWO conditions: +{"op": "addNode", "node": {"id": "cond-good", "data": {"config": {"condition": "{{@rate:Rate.value}} === 'good'"}}}} +{"op": "addNode", "node": {"id": "cond-bad", "data": {"config": {"condition": "{{@rate:Rate.value}} === 'bad'"}}}} +{"op": "addEdge", "edge": {"source": "rate", "target": "cond-good"}} +{"op": "addEdge", "edge": {"source": "rate", "target": "cond-bad"}} +{"op": "addEdge", "edge": {"source": "cond-good", "target": "slack"}} +{"op": "addEdge", "edge": {"source": "cond-bad", "target": "ticket"}} -TRANSFORM NODES: -- { transformType: "Map Data" } +Edge structure: +{ + "id": "edge-id", + "source": "source-node-id", + "target": "target-node-id", + "type": "default" +} -IMPORTANT: -- For Database Query actions, ALWAYS include a realistic SQL query in the "dbQuery" field -- For HTTP Request actions, include proper httpMethod, endpoint, httpHeaders, and httpBody -- For Send Email actions, include emailTo, emailSubject, and emailBody -- For Send Slack Message actions, include slackChannel and slackMessage -- Position nodes in a left-to-right flow with proper spacing (x: 100, 400, 700, etc., y: 200) -- Return ONLY valid JSON, no markdown or explanations`; +WORKFLOW FLOW: +- Trigger connects to first action +- Actions connect in sequence or to multiple branches +- ALWAYS create edges to connect the workflow flow +- For linear workflows: trigger -> action1 -> action2 -> etc +- For branching (conditions): one source can connect to multiple targets + +Example output: +{"op": "setName", "name": "Contact Form Workflow"} +{"op": "setDescription", "description": "Processes contact form submissions"} +{"op": "addNode", "node": {"id": "trigger-1", "type": "trigger", "position": {"x": 100, "y": 200}, "data": {"label": "Contact Form", "type": "trigger", "config": {"triggerType": "Manual"}, "status": "idle"}}} +{"op": "addNode", "node": {"id": "send-email", "type": "action", "position": {"x": 400, "y": 200}, "data": {"label": "Send Email", "type": "action", "config": {"actionType": "Send Email", "emailTo": "admin@example.com", "emailSubject": "New Contact", "emailBody": "New contact form submission"}, "status": "idle"}}} +{"op": "addEdge", "edge": {"id": "e1", "source": "trigger-1", "target": "send-email", "type": "default"}} + +REMEMBER: After adding all nodes, you MUST add edges to connect them! Every node should be reachable from the trigger.`; export async function POST(request: Request) { try { @@ -71,7 +237,7 @@ export async function POST(request: Request) { } const body = await request.json(); - const { prompt } = body; + const { prompt, existingWorkflow } = body; if (!prompt) { return NextResponse.json( @@ -80,21 +246,98 @@ export async function POST(request: Request) { ); } + const apiKey = process.env.AI_GATEWAY_API_KEY || process.env.OPENAI_API_KEY; + + if (!apiKey) { + return NextResponse.json( + { + error: "AI API key not configured on server. Please contact support.", + }, + { status: 500 } + ); + } + + // Build the user prompt + let userPrompt = prompt; + if (existingWorkflow) { + // Identify nodes and their labels for context + const nodesList = (existingWorkflow.nodes || []) + .map( + (n: { id: string; data?: { label?: string } }) => + `- ${n.id} (${n.data?.label || "Unlabeled"})` + ) + .join("\n"); + + const edgesList = (existingWorkflow.edges || []) + .map( + (e: { id: string; source: string; target: string }) => + `- ${e.id}: ${e.source} -> ${e.target}` + ) + .join("\n"); + + userPrompt = `I have an existing workflow. I want you to make ONLY the changes I request. + +Current workflow nodes: +${nodesList} + +Current workflow edges: +${edgesList} + +Full workflow data (DO NOT recreate these, they already exist): +${JSON.stringify(existingWorkflow, null, 2)} + +User's request: ${prompt} + +IMPORTANT: Output ONLY the operations needed to make the requested changes. +- If adding new nodes: output "addNode" operations for NEW nodes only, then IMMEDIATELY output "addEdge" operations to connect them to the workflow +- If adding new edges: output "addEdge" operations for NEW edges only +- If removing nodes: output "removeNode" operations with the nodeId to remove +- If removing edges: output "removeEdge" operations with the edgeId to remove +- If changing name/description: output "setName"/"setDescription" only if changed +- CRITICAL: New nodes MUST be connected with edges - always add edges after adding nodes +- When connecting nodes, look at the node IDs in the current workflow list above +- DO NOT output operations for existing nodes/edges unless specifically modifying them +- Keep the existing workflow structure and only add/modify/remove what was requested + +Example: If user says "connect node A to node B", output: +{"op": "addEdge", "edge": {"id": "e-new", "source": "A", "target": "B", "type": "default"}}`; + } + const result = streamText({ - model: process.env.AI_MODEL || "openai/gpt-4o-mini", + model: "openai/gpt-5.1-instant", system, - prompt, - temperature: 0.7, + prompt: userPrompt, }); - // Convert stream to text - let fullText = ""; - for await (const chunk of result.textStream) { - fullText += chunk; - } + // Create a streaming response + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + try { + await processOperationStream(result.textStream, encoder, controller); + controller.close(); + } catch (error) { + controller.enqueue( + encodeMessage(encoder, { + type: "error", + error: + error instanceof Error + ? error.message + : "Failed to generate workflow", + }) + ); + controller.close(); + } + }, + }); - const workflowData = JSON.parse(fullText); - return NextResponse.json(workflowData); + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); } catch (error) { console.error("Failed to generate workflow:", error); return NextResponse.json( diff --git a/app/workflows/[workflowId]/page.tsx b/app/workflows/[workflowId]/page.tsx index e5f6b07e2..23c272173 100644 --- a/app/workflows/[workflowId]/page.tsx +++ b/app/workflows/[workflowId]/page.tsx @@ -27,6 +27,7 @@ import { isSavingAtom, nodesAtom, propertiesPanelActiveTabAtom, + selectedExecutionIdAtom, selectedNodeAtom, updateNodeDataAtom, type WorkflowNode, @@ -47,6 +48,9 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { const [nodes] = useAtom(nodesAtom); const [edges] = useAtom(edgesAtom); const [currentWorkflowId] = useAtom(currentWorkflowIdAtom); + const [selectedExecutionId, setSelectedExecutionId] = useAtom( + selectedExecutionIdAtom + ); const setNodes = useSetAtom(nodesAtom); const setEdges = useSetAtom(edgesAtom); const setCurrentWorkflowId = useSetAtom(currentWorkflowIdAtom); @@ -59,6 +63,17 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { // Ref to track polling interval const executionPollingIntervalRef = useRef(null); + // Ref to track polling interval for selected execution + const selectedExecutionPollingIntervalRef = useRef( + null + ); + // Ref to access current nodes without triggering effect re-runs + const nodesRef = useRef(nodes); + + // Keep nodes ref in sync + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); // Helper function to generate workflow from AI const generateWorkflowFromAI = useCallback( @@ -301,6 +316,9 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { const result = await response.json(); + // Select the new execution + setSelectedExecutionId(result.executionId); + // Poll for execution status updates const pollInterval = setInterval(async () => { const { isComplete, status } = await pollExecutionStatus( @@ -319,10 +337,8 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { setIsExecuting(false); - // Reset node statuses after 5 seconds - setTimeout(() => { - updateAllNodeStatuses("idle"); - }, 5000); + // Don't reset node statuses - let them show the final state + // The user can click another run or deselect to reset } }, 500); // Poll every 500ms @@ -349,6 +365,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { setNodes, setEdges, setSelectedNodeId, + setSelectedExecutionId, ]); // Helper to check if target is an input element @@ -420,10 +437,81 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { if (executionPollingIntervalRef.current) { clearInterval(executionPollingIntervalRef.current); } + if (selectedExecutionPollingIntervalRef.current) { + clearInterval(selectedExecutionPollingIntervalRef.current); + } }, [] ); + // Poll for selected execution status + useEffect(() => { + // Clear existing interval if any + if (selectedExecutionPollingIntervalRef.current) { + clearInterval(selectedExecutionPollingIntervalRef.current); + selectedExecutionPollingIntervalRef.current = null; + } + + // If no execution is selected or it's the currently running one, don't poll + if (!selectedExecutionId) { + // Reset all node statuses when no execution is selected + for (const node of nodesRef.current) { + updateNodeData({ id: node.id, data: { status: "idle" } }); + } + return; + } + + // Start polling for the selected execution + const pollSelectedExecution = async () => { + try { + const statusData = + await api.workflow.getExecutionStatus(selectedExecutionId); + + // Update node statuses based on the execution logs + for (const nodeStatus of statusData.nodeStatuses) { + updateNodeData({ + id: nodeStatus.nodeId, + data: { + status: nodeStatus.status as + | "idle" + | "running" + | "success" + | "error", + }, + }); + } + + // Stop polling if execution is complete + if ( + statusData.status !== "running" && + selectedExecutionPollingIntervalRef.current + ) { + clearInterval(selectedExecutionPollingIntervalRef.current); + selectedExecutionPollingIntervalRef.current = null; + } + } catch (error) { + console.error("Failed to poll selected execution status:", error); + // Clear polling on error + if (selectedExecutionPollingIntervalRef.current) { + clearInterval(selectedExecutionPollingIntervalRef.current); + selectedExecutionPollingIntervalRef.current = null; + } + } + }; + + // Poll immediately and then every 500ms + pollSelectedExecution(); + const pollInterval = setInterval(pollSelectedExecution, 500); + selectedExecutionPollingIntervalRef.current = pollInterval; + + return () => { + if (selectedExecutionPollingIntervalRef.current) { + clearInterval(selectedExecutionPollingIntervalRef.current); + selectedExecutionPollingIntervalRef.current = null; + } + }; + }, [selectedExecutionId, updateNodeData]); + return (
diff --git a/components/ai-elements/canvas.tsx b/components/ai-elements/canvas.tsx index 02faf7c8f..1312424ef 100644 --- a/components/ai-elements/canvas.tsx +++ b/components/ai-elements/canvas.tsx @@ -12,8 +12,6 @@ export const Canvas = ({ children, ...props }: CanvasProps) => { deleteKeyCode={["Backspace", "Delete"]} fitView panActivationKeyCode={null} - panOnDrag={[1, 2]} - panOnScroll selectionOnDrag={false} zoomOnDoubleClick={false} zoomOnPinch diff --git a/components/ai-elements/prompt.tsx b/components/ai-elements/prompt.tsx new file mode 100644 index 000000000..0f9ca9599 --- /dev/null +++ b/components/ai-elements/prompt.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useAtom, useAtomValue } from "jotai"; +import { ArrowUp } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api-client"; +import { + currentWorkflowIdAtom, + currentWorkflowNameAtom, + edgesAtom, + isGeneratingAtom, + nodesAtom, + selectedNodeAtom, +} from "@/lib/workflow-store"; + +type AIPromptProps = { + workflowId?: string; + onWorkflowCreated?: (workflowId: string) => void; +}; + +export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { + const [isGenerating, setIsGenerating] = useAtom(isGeneratingAtom); + const [prompt, setPrompt] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + const nodes = useAtomValue(nodesAtom); + const [edges, setEdges] = useAtom(edgesAtom); + const [_nodes, setNodes] = useAtom(nodesAtom); + const [_currentWorkflowId, setCurrentWorkflowId] = useAtom(currentWorkflowIdAtom); + const [_currentWorkflowName, setCurrentWorkflowName] = useAtom(currentWorkflowNameAtom); + const [_selectedNodeId, setSelectedNodeId] = useAtom(selectedNodeAtom); + + // Filter out placeholder "add" nodes to get real nodes + const realNodes = nodes.filter((node) => node.type !== "add"); + const hasNodes = realNodes.length > 0; + + // Focus input when Cmd/Ctrl + K is pressed + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + inputRef.current?.focus(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + const handleFocus = () => { + setIsExpanded(true); + setIsFocused(true); + }; + + const handleBlur = () => { + setIsFocused(false); + if (!prompt.trim()) { + setIsExpanded(false); + } + }; + + const handleGenerate = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + + if (!prompt.trim() || isGenerating) { + return; + } + + setIsGenerating(true); + + try { + // Send existing workflow data for context when modifying + const existingWorkflow = hasNodes + ? { nodes: realNodes, edges, name: _currentWorkflowName } + : undefined; + + console.log("[AI Prompt] Generating workflow"); + console.log("[AI Prompt] Has nodes:", hasNodes); + console.log("[AI Prompt] Sending existing workflow:", !!existingWorkflow); + if (existingWorkflow) { + console.log( + "[AI Prompt] Existing workflow:", + existingWorkflow.nodes.length, + "nodes,", + existingWorkflow.edges.length, + "edges" + ); + } + + // Use streaming API with incremental updates + const workflowData = await api.ai.generateStream( + prompt, + (partialData) => { + // Update UI incrementally with animated edges + const edgesWithAnimatedType = (partialData.edges || []).map((edge) => ({ + ...edge, + type: "animated", + })); + + // Validate: ensure only ONE trigger node exists + const triggerNodes = (partialData.nodes || []).filter( + (node) => node.data?.type === "trigger" + ); + + let validEdges = edgesWithAnimatedType; + + if (triggerNodes.length > 1) { + // Keep only the first trigger and all non-trigger nodes + const firstTrigger = triggerNodes[0]; + const nonTriggerNodes = (partialData.nodes || []).filter( + (node) => node.data?.type !== "trigger" + ); + partialData.nodes = [firstTrigger, ...nonTriggerNodes]; + + // Remove edges connected to removed triggers + const removedTriggerIds = triggerNodes.slice(1).map((n) => n.id); + validEdges = edgesWithAnimatedType.filter( + (edge) => + !removedTriggerIds.includes(edge.source) && + !removedTriggerIds.includes(edge.target) + ); + } + + // Update the canvas incrementally + setNodes(partialData.nodes || []); + setEdges(validEdges); + if (partialData.name) { + setCurrentWorkflowName(partialData.name); + } + }, + existingWorkflow + ); + + console.log("[AI Prompt] Received final workflow data"); + console.log("[AI Prompt] Nodes:", workflowData.nodes?.length || 0); + console.log("[AI Prompt] Edges:", workflowData.edges?.length || 0); + + // Use edges from workflow data with animated type + const finalEdges = (workflowData.edges || []).map((edge) => ({ + ...edge, + type: "animated", + })); + + // Validate: check for blank/incomplete nodes + console.log("[AI Prompt] Validating nodes:", workflowData.nodes); + const incompleteNodes = (workflowData.nodes || []).filter((node) => { + const nodeType = node.data?.type; + const config = node.data?.config || {}; + + console.log(`[AI Prompt] Checking node ${node.id}:`, { + type: nodeType, + config, + hasActionType: !!config.actionType, + hasTriggerType: !!config.triggerType, + }); + + // Check trigger nodes + if (nodeType === "trigger") { + return !config.triggerType; + } + + // Check action nodes + if (nodeType === "action") { + return !config.actionType; + } + + // Allow other node types (condition, transform) without strict validation + return false; + }); + + if (incompleteNodes.length > 0) { + console.error( + "[AI Prompt] AI generated incomplete nodes:", + incompleteNodes + ); + console.error( + "[AI Prompt] Full workflow data:", + JSON.stringify(workflowData, null, 2) + ); + throw new Error( + `Cannot create workflow: The AI tried to create ${incompleteNodes.length} incomplete node(s). The requested action type may not be supported. Please try a different description using supported actions: Send Email, Send Slack Message, Create Ticket, Database Query, HTTP Request, Generate Text, or Generate Image.` + ); + } + + // If no workflowId, create a new workflow + if (!workflowId) { + const newWorkflow = await api.workflow.create({ + name: workflowData.name || "AI Generated Workflow", + description: workflowData.description || "", + nodes: workflowData.nodes || [], + edges: finalEdges, + }); + + // State already updated by streaming callback + setCurrentWorkflowId(newWorkflow.id); + + toast.success("Created workflow"); + + // Notify parent component to redirect + if (onWorkflowCreated) { + onWorkflowCreated(newWorkflow.id); + } + } else { + setCurrentWorkflowId(workflowId); + + console.log("[AI Prompt] Updating existing workflow:", workflowId); + console.log("[AI Prompt] Has existingWorkflow context:", !!existingWorkflow); + + // State already updated by streaming callback + if (existingWorkflow) { + console.log("[AI Prompt] REPLACING workflow with AI response"); + console.log( + "[AI Prompt] Replacing", + realNodes.length, + "nodes with", + workflowData.nodes?.length || 0, + "nodes" + ); + } else { + console.log("[AI Prompt] Setting workflow for empty canvas"); + + toast.success("Generated workflow"); + } + + const selectedNode = workflowData.nodes?.find( + (n: { selected?: boolean }) => n.selected + ); + if (selectedNode) { + setSelectedNodeId(selectedNode.id); + } + + // Save the updated workflow + await api.workflow.update(workflowId, { + name: workflowData.name, + description: workflowData.description, + nodes: workflowData.nodes, + edges: finalEdges, + }); + } + + // Clear and close + setPrompt(""); + setIsExpanded(false); + inputRef.current?.blur(); + } catch (error) { + console.error("Failed to generate workflow:", error); + toast.error("Failed to generate workflow"); + } finally { + setIsGenerating(false); + } + }, + [ + prompt, + isGenerating, + workflowId, + hasNodes, + nodes, + edges, + setIsGenerating, + setCurrentWorkflowId, + setNodes, + setEdges, + setCurrentWorkflowName, + setSelectedNodeId, + onWorkflowCreated, + ] + ); + + return ( + <> + {/* Always visible prompt input */} +
+
+ {isGenerating && prompt ? ( + + {prompt} + + ) : ( +