Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
342 changes: 285 additions & 57 deletions app/api/ai/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,214 @@ import { streamText } from "ai";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";

const system = `You are a workflow automation expert. Generate a workflow based on the user's description.
// 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;
};
};

Return a JSON object with this 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"
function encodeMessage(encoder: TextEncoder, message: object): Uint8Array {
return encoder.encode(`${JSON.stringify(message)}\n`);
}

async function processOperationStream(
textStream: AsyncIterable<string>,
encoder: TextEncoder,
controller: ReadableStreamDefaultController
): Promise<void> {
let buffer = "";
let operationCount = 0;
let chunkCount = 0;

for await (const chunk of textStream) {
chunkCount += 1;
buffer += chunk;

// Split by newlines and process complete lines
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep incomplete line in buffer

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("```")) {
continue;
}

try {
const operation = JSON.parse(trimmed) as Operation;
operationCount += 1;

console.log(`[API] Operation ${operationCount}:`, operation.op);

// Send operation immediately
controller.enqueue(
encodeMessage(encoder, {
type: "operation",
operation,
})
);
} catch {
// Skip invalid JSON lines
console.warn(
"[API] Skipping invalid JSON line:",
trimmed.substring(0, 50)
);
}
}
],
"edges": [
{
"id": "edge-id",
"source": "source-node-id",
"target": "target-node-id",
"type": "default"
}

// Process any remaining buffer content
if (buffer.trim()) {
const trimmed = buffer.trim();
if (!trimmed.startsWith("```")) {
try {
const operation = JSON.parse(trimmed) as Operation;
operationCount += 1;

console.log(
`[API] Operation ${operationCount} (from buffer):`,
operation.op
);

// Send operation immediately
controller.enqueue(
encodeMessage(encoder, {
type: "operation",
operation,
})
);
} catch {
console.warn(
"[API] Failed to parse remaining buffer:",
trimmed.substring(0, 100)
);
}
}
]
}

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.

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:
{
"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 {
Expand All @@ -71,7 +222,7 @@ export async function POST(request: Request) {
}

const body = await request.json();
const { prompt } = body;
const { prompt, existingWorkflow } = body;

if (!prompt) {
return NextResponse.json(
Expand All @@ -80,21 +231,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(
Expand Down
Loading