Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 10 additions & 12 deletions app/api/ai/generate/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { streamText } from "ai";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { generateAIActionPrompts } from "@/plugins";

// Simple type for operations
type Operation = {
Expand Down Expand Up @@ -130,7 +131,9 @@ async function processOperationStream(
);
}

const system = `You are a workflow automation expert. Generate a workflow based on the user's description.
function getSystemPrompt(): string {
const pluginActionPrompts = generateAIActionPrompts();
return `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.
Expand Down Expand Up @@ -185,19 +188,13 @@ Trigger types:
- Webhook: {"triggerType": "Webhook", "webhookPath": "/webhooks/name", ...}
- Schedule: {"triggerType": "Schedule", "scheduleCron": "0 9 * * *", ...}

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"}
System action types (built-in):
- 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": "meta/llama-4-scout", "aiFormat": "text", "aiPrompt": "Your prompt here"}
- Generate Image: {"actionType": "Generate Image", "imageModel": "google/imagen-4.0-generate", "imagePrompt": "Image description"}
- Scrape: {"actionType": "Scrape", "url": "https://example.com"}
- Search: {"actionType": "Search", "query": "search query", "limit": 10}
- Condition: {"actionType": "Condition", "condition": "{{@nodeId:Label.field}} === 'value'"}
- Create Chat (v0): {"actionType": "Create Chat", "message": "Create a line graph showing DAU over time", "system": "You are an expert coder"} - Use v0 for generating UI components, visualizations (charts, graphs, dashboards), landing pages, or any React/Next.js code. PREFER v0 over Generate Text/Image for any visual output like charts, graphs, or UI.
- Send Message (v0): {"actionType": "Send Message", "chatId": "{{@nodeId:Label.chatId}}", "message": "Add dark mode"} - Use this to continue a v0 chat conversation

Plugin action types (from integrations):
${pluginActionPrompts}

CRITICAL ABOUT CONDITION NODES:
- Condition nodes evaluate a boolean expression
Expand Down Expand Up @@ -247,6 +244,7 @@ Example output (branching workflow with 250px vertical spacing):
{"op": "addEdge", "edge": {"id": "e2", "source": "trigger-1", "target": "branch-b", "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 Down Expand Up @@ -328,7 +326,7 @@ Example: If user says "connect node A to node B", output:

const result = streamText({
model: "openai/gpt-5.1-instant",
system,
system: getSystemPrompt(),
prompt: userPrompt,
});

Expand Down
91 changes: 48 additions & 43 deletions app/api/workflows/[workflowId]/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { db } from "@/lib/db";
import { workflows } from "@/lib/db/schema";
import { generateWorkflowModule } from "@/lib/workflow-codegen";
import type { WorkflowEdge, WorkflowNode } from "@/lib/workflow-store";
import { getAllEnvVars, getDependenciesForActions } from "@/plugins";

// Path to the Next.js boilerplate directory
const BOILERPLATE_PATH = join(process.cwd(), "lib", "next-boilerplate");
Expand Down Expand Up @@ -137,35 +138,58 @@ export async function POST(request: Request) {

/**
* Get npm dependencies based on workflow nodes
* Uses the plugin registry to dynamically determine required dependencies
*/
function getIntegrationDependencies(
nodes: WorkflowNode[]
): Record<string, string> {
const deps: Record<string, string> = {};

for (const node of nodes) {
const actionType = node.data.config?.actionType as string;

if (actionType === "Send Email") {
deps.resend = "^6.4.0";
} else if (actionType === "Create Ticket" || actionType === "Find Issues") {
deps["@linear/sdk"] = "^63.2.0";
} else if (actionType === "Send Slack Message") {
deps["@slack/web-api"] = "^7.12.0";
} else if (
actionType === "Generate Text" ||
actionType === "Generate Image"
) {
deps.ai = "^5.0.86";
deps.openai = "^6.8.0";
deps["@google/genai"] = "^1.28.0";
deps.zod = "^4.1.12";
} else if (actionType === "Scrape" || actionType === "Search") {
deps["@mendable/firecrawl-js"] = "^4.6.2";
// Collect all action types used in the workflow
const actionTypes = nodes
.filter((node) => node.data.type === "action")
.map((node) => node.data.config?.actionType as string)
.filter(Boolean);

// Get dependencies from plugin registry
return getDependenciesForActions(actionTypes);
}

/**
* Generate .env.example content based on registered integrations
*/
function generateEnvExample(): string {
const lines = ["# Add your environment variables here"];

// Add system integration env vars
lines.push("");
lines.push("# For database integrations");
lines.push("DATABASE_URL=your_database_url");

// Add plugin env vars from registry
const envVars = getAllEnvVars();
const groupedByPrefix: Record<
string,
Array<{ name: string; description: string }>
> = {};

for (const envVar of envVars) {
const prefix = envVar.name.split("_")[0];
if (!groupedByPrefix[prefix]) {
groupedByPrefix[prefix] = [];
}
groupedByPrefix[prefix].push(envVar);
}

for (const [prefix, vars] of Object.entries(groupedByPrefix)) {
lines.push(
`# For ${prefix.charAt(0) + prefix.slice(1).toLowerCase()} integration`
);
for (const v of vars) {
lines.push(`${v.name}=your_${v.name.toLowerCase()}`);
}
lines.push("");
}

return deps;
return lines.join("\n");
}

/**
Expand Down Expand Up @@ -303,27 +327,8 @@ vercel deploy
For more information, visit the [Workflow documentation](https://workflow.is).
`;

// Add .env.example file
allFiles[".env.example"] = `# Add your environment variables here
# For Resend email integration
RESEND_API_KEY=your_resend_api_key

# For Linear integration
LINEAR_API_KEY=your_linear_api_key

# For Slack integration
SLACK_BOT_TOKEN=your_slack_bot_token

# For AI integrations
OPENAI_API_KEY=your_openai_api_key
GOOGLE_AI_API_KEY=your_google_ai_api_key

# For database integrations
DATABASE_URL=your_database_url

# For Firecrawl integration
FIRECRAWL_API_KEY=your_firecrawl_api_key
`;
// Add .env.example file (dynamically generated from plugin registry)
allFiles[".env.example"] = generateEnvExample();

return NextResponse.json({
success: true,
Expand Down
32 changes: 4 additions & 28 deletions components/ui/integration-icon.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import { Database, HelpCircle } from "lucide-react";
import Image from "next/image";
import type { IntegrationType } from "@/lib/types/integration";
import { cn } from "@/lib/utils";
import { getIntegration } from "@/plugins";
Expand All @@ -27,14 +26,13 @@ function VercelIcon({ className }: { className?: string }) {
);
}

// Special icons for integrations without plugins (database, vercel, ai-gateway)
// Special icons for integrations without plugins (database, vercel)
const SPECIAL_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
database: Database,
vercel: VercelIcon,
"ai-gateway": VercelIcon,
};

export function IntegrationIcon({
Expand All @@ -50,31 +48,9 @@ export function IntegrationIcon({
// Look up plugin from registry
const plugin = getIntegration(integration as IntegrationType);

if (plugin) {
const { icon } = plugin;

// Handle image type icons
if (icon.type === "image") {
return (
<Image
alt={`${plugin.label} logo`}
className={className}
height={12}
src={icon.value}
width={12}
/>
);
}

// Handle SVG component icons
if (icon.type === "svg" && icon.svgComponent) {
const SvgComponent = icon.svgComponent;
return <SvgComponent className={cn("text-foreground", className)} />;
}

// Handle lucide icons - these are already React components in plugin.actions
// For plugin-level icons, we would need to dynamically import lucide icons
// For now, fall through to default
if (plugin?.icon) {
const PluginIcon = plugin.icon;
return <PluginIcon className={cn("text-foreground", className)} />;
}

// Fallback for unknown integrations
Expand Down
42 changes: 32 additions & 10 deletions components/ui/template-autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,23 +86,36 @@ const schemaToFields = (
return fields;
};

// Helper to check if action type matches (supports both namespaced IDs and legacy labels)
const isActionType = (
actionType: string | undefined,
...matches: string[]
): boolean => {
if (!actionType) return false;
return matches.some(
(match) =>
actionType === match ||
actionType.endsWith(`/${match.toLowerCase().replace(/\s+/g, "-")}`)
);
};

// Get common fields based on node action type
const getCommonFields = (node: WorkflowNode) => {
const actionType = node.data.config?.actionType;
const actionType = node.data.config?.actionType as string | undefined;

if (actionType === "Find Issues") {
if (isActionType(actionType, "Find Issues", "linear/find-issues")) {
return [
{ field: "issues", description: "Array of issues found" },
{ field: "count", description: "Number of issues" },
];
}
if (actionType === "Send Email") {
if (isActionType(actionType, "Send Email", "resend/send-email")) {
return [
{ field: "id", description: "Email ID" },
{ field: "status", description: "Send status" },
];
}
if (actionType === "Create Ticket") {
if (isActionType(actionType, "Create Ticket", "linear/create-ticket")) {
return [
{ field: "id", description: "Ticket ID" },
{ field: "url", description: "Ticket URL" },
Expand Down Expand Up @@ -136,7 +149,7 @@ const getCommonFields = (node: WorkflowNode) => {
{ field: "count", description: "Number of rows" },
];
}
if (actionType === "Generate Text") {
if (isActionType(actionType, "Generate Text", "ai-gateway/generate-text")) {
const aiFormat = node.data.config?.aiFormat as string | undefined;
const aiSchema = node.data.config?.aiSchema as string | undefined;

Expand All @@ -158,13 +171,15 @@ const getCommonFields = (node: WorkflowNode) => {
{ field: "model", description: "Model used" },
];
}
if (actionType === "Generate Image") {
if (isActionType(actionType, "Generate Image", "ai-gateway/generate-image")) {
return [
{ field: "base64", description: "Base64 image data" },
{ field: "model", description: "Model used" },
];
}
if (actionType === "Scrape") {
if (
isActionType(actionType, "Scrape", "Scrape URL", "firecrawl/scrape")
) {
return [
{ field: "markdown", description: "Scraped content as markdown" },
{ field: "metadata.url", description: "Page URL" },
Expand All @@ -174,22 +189,29 @@ const getCommonFields = (node: WorkflowNode) => {
{ field: "metadata.favicon", description: "Favicon URL" },
];
}
if (actionType === "Search") {
if (isActionType(actionType, "Search", "Search Web", "firecrawl/search")) {
return [{ field: "web", description: "Array of search results" }];
}
if (actionType === "Create Chat") {
if (isActionType(actionType, "Create Chat", "v0/create-chat")) {
return [
{ field: "chatId", description: "v0 chat ID" },
{ field: "url", description: "v0 chat URL" },
{ field: "demoUrl", description: "Demo preview URL" },
];
}
if (actionType === "Send Message") {
if (isActionType(actionType, "Send Message", "v0/send-message")) {
return [
{ field: "chatId", description: "v0 chat ID" },
{ field: "demoUrl", description: "Demo preview URL" },
];
}
if (isActionType(actionType, "Send Slack Message", "slack/send-message")) {
return [
{ field: "ok", description: "Success status" },
{ field: "ts", description: "Message timestamp" },
{ field: "channel", description: "Channel ID" },
];
}
if (node.data.type === "trigger") {
const triggerType = node.data.config?.triggerType as string | undefined;
const webhookSchema = node.data.config?.webhookSchema as string | undefined;
Expand Down
15 changes: 13 additions & 2 deletions components/ui/template-badge-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ export interface TemplateBadgeInputProps {
id?: string;
}

// Helper to check if a template references an existing node
function doesNodeExist(template: string, nodes: ReturnType<typeof useAtom<typeof nodesAtom>>[0]): boolean {
const match = template.match(/\{\{@([^:]+):([^}]+)\}\}/);
if (!match) return false;

const nodeId = match[1];
return nodes.some((n) => n.id === nodeId);
}

// Helper to get display text from template by looking up current node label
function getDisplayTextForTemplate(template: string, nodes: ReturnType<typeof useAtom<typeof nodesAtom>>[0]): string {
// Extract nodeId and field from template: {{@nodeId:OldLabel.field}}
Expand Down Expand Up @@ -248,8 +257,10 @@ export function TemplateBadgeInput({

// Create badge for template
const badge = document.createElement("span");
badge.className =
"inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-blue-600 dark:text-blue-400 font-mono text-xs border border-blue-500/20 mx-0.5";
const nodeExists = doesNodeExist(fullMatch, nodes);
badge.className = nodeExists
? "inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-blue-600 dark:text-blue-400 font-mono text-xs border border-blue-500/20 mx-0.5"
: "inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-red-600 dark:text-red-400 font-mono text-xs border border-red-500/20 mx-0.5";
badge.contentEditable = "false";
badge.setAttribute("data-template", fullMatch);
// Use current node label for display
Expand Down
15 changes: 13 additions & 2 deletions components/ui/template-badge-textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ export interface TemplateBadgeTextareaProps {
rows?: number;
}

// Helper to check if a template references an existing node
function doesNodeExist(template: string, nodes: ReturnType<typeof useAtom<typeof nodesAtom>>[0]): boolean {
const match = template.match(/\{\{@([^:]+):([^}]+)\}\}/);
if (!match) return false;

const nodeId = match[1];
return nodes.some((n) => n.id === nodeId);
}

// Helper to get display text from template by looking up current node label
function getDisplayTextForTemplate(template: string, nodes: ReturnType<typeof useAtom<typeof nodesAtom>>[0]): string {
// Extract nodeId and field from template: {{@nodeId:OldLabel.field}}
Expand Down Expand Up @@ -273,8 +282,10 @@ export function TemplateBadgeTextarea({

// Create badge for template
const badge = document.createElement("span");
badge.className =
"inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-blue-600 dark:text-blue-400 font-mono text-xs border border-blue-500/20 mx-0.5";
const nodeExists = doesNodeExist(fullMatch, nodes);
badge.className = nodeExists
? "inline-flex items-center gap-1 rounded bg-blue-500/10 px-1.5 py-0.5 text-blue-600 dark:text-blue-400 font-mono text-xs border border-blue-500/20 mx-0.5"
: "inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-red-600 dark:text-red-400 font-mono text-xs border border-red-500/20 mx-0.5";
badge.contentEditable = "false";
badge.setAttribute("data-template", fullMatch);
// Use current node label for display
Expand Down
Loading