Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
77 changes: 52 additions & 25 deletions components/workflow/config/action-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,39 +199,70 @@ function ConditionFields({
}

// System actions that don't have plugins
const SYSTEM_ACTIONS = ["HTTP Request", "Database Query", "Condition"];
const SYSTEM_ACTIONS: Array<{ id: string; label: string }> = [
{ id: "HTTP Request", label: "HTTP Request" },
{ id: "Database Query", label: "Database Query" },
{ id: "Condition", label: "Condition" },
];

const SYSTEM_ACTION_IDS = SYSTEM_ACTIONS.map((a) => a.id);

// Build category mapping dynamically from plugins + System
function useCategoryData() {
return useMemo(() => {
const pluginCategories = getActionsByCategory();

// Build category map including System
const allCategories: Record<string, string[]> = {
// Build category map including System with both id and label
const allCategories: Record<
string,
Array<{ id: string; label: string }>
> = {
System: SYSTEM_ACTIONS,
};

for (const [category, actions] of Object.entries(pluginCategories)) {
allCategories[category] = actions.map((a) => a.id);
allCategories[category] = actions.map((a) => ({
id: a.id,
label: a.label,
}));
}

return allCategories;
}, []);
}

// Get category for an action type
function getCategoryForAction(
actionType: string,
categories: Record<string, string[]>
): string | null {
for (const [category, actions] of Object.entries(categories)) {
if (actions.includes(actionType)) {
return category;
}
// Get category for an action type (supports both new IDs, labels, and legacy labels)
function getCategoryForAction(actionType: string): string | null {
// Check system actions first
if (SYSTEM_ACTION_IDS.includes(actionType)) {
return "System";
}

// Use findActionById which handles legacy labels from plugin registry
const action = findActionById(actionType);
if (action?.category) {
return action.category;
}

return null;
}

// Normalize action type to new ID format (handles legacy labels via findActionById)
function normalizeActionType(actionType: string): string {
// Check system actions first - they use their label as ID
if (SYSTEM_ACTION_IDS.includes(actionType)) {
return actionType;
}

// Use findActionById which handles legacy labels and returns the proper ID
const action = findActionById(actionType);
if (action) {
return action.id;
}

return actionType;
}

export function ActionConfig({
config,
onUpdateConfig,
Expand All @@ -241,25 +272,21 @@ export function ActionConfig({
const categories = useCategoryData();
const integrations = useMemo(() => getAllIntegrations(), []);

const selectedCategory = actionType
? getCategoryForAction(actionType, categories)
: null;
const selectedCategory = actionType ? getCategoryForAction(actionType) : null;
const [category, setCategory] = useState<string>(selectedCategory || "");

// Sync category state when actionType changes (e.g., when switching nodes)
useEffect(() => {
const newCategory = actionType
? getCategoryForAction(actionType, categories)
: null;
const newCategory = actionType ? getCategoryForAction(actionType) : null;
setCategory(newCategory || "");
}, [actionType, categories]);
}, [actionType]);

const handleCategoryChange = (newCategory: string) => {
setCategory(newCategory);
// Auto-select the first action in the new category
const firstAction = categories[newCategory]?.[0];
if (firstAction) {
onUpdateConfig("actionType", firstAction);
onUpdateConfig("actionType", firstAction.id);
}
};

Expand Down Expand Up @@ -319,16 +346,16 @@ export function ActionConfig({
<Select
disabled={disabled || !category}
onValueChange={handleActionTypeChange}
value={actionType || undefined}
value={normalizeActionType(actionType) || undefined}
>
<SelectTrigger className="w-full" id="actionType">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
{category &&
categories[category]?.map((action) => (
<SelectItem key={action} value={action}>
{action}
<SelectItem key={action.id} value={action.id}>
{action.label}
</SelectItem>
))}
</SelectContent>
Expand Down Expand Up @@ -362,7 +389,7 @@ export function ActionConfig({
)}

{/* Plugin actions - dynamic config fields */}
{pluginAction && !SYSTEM_ACTIONS.includes(actionType) && (
{pluginAction && !SYSTEM_ACTION_IDS.includes(actionType) && (
<pluginAction.configFields
config={config}
disabled={disabled}
Expand Down
26 changes: 24 additions & 2 deletions components/workflow/nodes/action-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,29 @@ const getProviderLogo = (actionType: string) => {
break;
}

// Look up action in plugin registry
// Look up action in plugin registry and get the integration icon
const action = findActionById(actionType);
if (action) {
const plugin = getIntegration(action.integration);
if (plugin?.icon) {
// Render integration logo based on icon type
if (plugin.icon.type === "image") {
return (
<Image
alt={plugin.label}
className="size-12"
height={48}
src={plugin.icon.value}
width={48}
/>
);
}
if (plugin.icon.type === "svg" && plugin.icon.svgComponent) {
const SvgIcon = plugin.icon.svgComponent;
return <SvgIcon className="size-12" />;
}
}
// Fall back to action icon if no integration icon
const ActionIcon = action.icon;
return <ActionIcon className="size-12" />;
}
Expand Down Expand Up @@ -292,7 +312,9 @@ export const ActionNode = memo(({ data, selected, id }: ActionNodeProps) => {
);
}

const displayTitle = data.label || actionType;
// Get human-readable label from registry if no custom label is set
const actionInfo = findActionById(actionType);
const displayTitle = data.label || actionInfo?.label || actionType;
const displayDescription =
data.description || getIntegrationFromActionType(actionType);

Expand Down
4 changes: 3 additions & 1 deletion components/workflow/workflow-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ function getMissingIntegrations(
// Check if user has any integration of this type
if (!userIntegrationTypes.has(requiredIntegrationType)) {
const existing = missingByType.get(requiredIntegrationType) || [];
existing.push(node.data.label || actionType);
// Use human-readable label from registry if no custom label
const actionInfo = findActionById(actionType);
existing.push(node.data.label || actionInfo?.label || actionType);
missingByType.set(requiredIntegrationType, existing);
}
}
Expand Down
Loading