Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started.

<!-- PLUGINS:START - Do not remove. Auto-generated by discover-plugins -->
- **AI Gateway**: Generate Text, Generate Image
- **Apify**: Run Apify Actor, Scrape Single URL
- **Firecrawl**: Scrape URL, Search Web
- **Linear**: Create Ticket, Find Issues
- **Resend**: Send Email
Expand Down
37 changes: 37 additions & 0 deletions app/api/integrations/[integrationId]/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { LinearClient } from "@linear/sdk";
import FirecrawlApp from "@mendable/firecrawl-js";
import { WebClient } from "@slack/web-api";
import { createGateway } from "ai";
import { ApifyClient } from "apify-client";
import { NextResponse } from "next/server";
import postgres from "postgres";
import { Resend } from "resend";
Expand Down Expand Up @@ -68,6 +69,9 @@ export async function POST(
integration.config.firecrawlApiKey
);
break;
case "apify":
result = await testApifyConnection(integration.config.apifyApiKey);
break;
default:
return NextResponse.json(
{ error: "Invalid integration type" },
Expand Down Expand Up @@ -281,3 +285,36 @@ async function testFirecrawlConnection(
};
}
}

async function testApifyConnection(
apiKey?: string
): Promise<TestConnectionResult> {
try {
if (!apiKey) {
return {
status: "error",
message: "Apify API Token is not configured",
};
}

const client = new ApifyClient({ token: apiKey });
const user = await client.user("me").get();

if (!user.username) {
return {
status: "error",
message: "Failed to verify API token",
};
}

return {
status: "success",
message: `Connected as ${user.username}`,
};
} catch (error) {
return {
status: "error",
message: error instanceof Error ? error.message : "Connection failed",
};
}
}
145 changes: 145 additions & 0 deletions components/ui/template-badge-json.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { TemplateBadgeTextarea } from "./template-badge-textarea";

export interface TemplateBadgeJsonProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
id?: string;
rows?: number;
}

/**
* A textarea component that validates JSON input in real-time
* Wraps TemplateBadgeTextarea and adds JSON validation and formatting
*/
export function TemplateBadgeJson({
value = "",
onChange,
placeholder,
disabled,
className,
id,
rows = 3,
}: TemplateBadgeJsonProps) {
const [jsonError, setJsonError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
const formatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastFormattedValueRef = useRef<string>("");

// Validate JSON on value change
useEffect(() => {
if (!value || typeof value !== "string") {
setJsonError(null);
return;
}
Comment on lines +37 to +40

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would split this into 2 conditions, so that if value is not string, some error message like "Value must be string" is displayed. Now the user can just type in a number and the component pretends its valid JSON:

image


// If empty or only whitespace, no error
if (!value.trim()) {
setJsonError(null);
return;
}

// Parse JSON directly - template variables will be treated as normal strings
try {
JSON.parse(value);
setJsonError(null);
} catch (error) {
setJsonError(
error instanceof Error ? error.message : "Invalid JSON format"
);
}
}, [value]);

// Format JSON when it becomes valid (debounced to avoid formatting while typing)
useEffect(() => {
// Clear any pending format timeout
if (formatTimeoutRef.current) {
clearTimeout(formatTimeoutRef.current);
}

// Don't format if there's an error, field is focused, or value is empty
if (jsonError || isFocused || !value || typeof value !== "string") {
return;
}

if (!value.trim()) {
return;
}

// Debounce formatting - wait 500ms after user stops typing
formatTimeoutRef.current = setTimeout(() => {
try {
// Parse JSON directly - template variables are treated as normal strings
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);

// Only format if different from current value and we haven't already formatted this value
if (formatted !== value && formatted !== lastFormattedValueRef.current) {
lastFormattedValueRef.current = formatted;
onChange?.(formatted);
}
} catch {
// If parsing fails, don't format
}
}, 500);

return () => {
if (formatTimeoutRef.current) {
clearTimeout(formatTimeoutRef.current);
}
};
}, [value, isFocused, jsonError, onChange]);

// Track focus state by listening to focus/blur events on the wrapper
const handleWrapperFocus = () => {
setIsFocused(true);
};

const handleWrapperBlur = () => {
setIsFocused(false);
// Format immediately on blur if JSON is valid
if (!jsonError && value && typeof value === "string" && value.trim()) {
try {
// Parse JSON directly - template variables are treated as normal strings
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);

if (formatted !== value) {
onChange?.(formatted);
}
} catch {
// If parsing fails, don't format
}
}
};

return (
<div
className="space-y-1"
onBlur={handleWrapperBlur}
onFocus={handleWrapperFocus}
>
<TemplateBadgeTextarea
className={cn(
jsonError && "border-destructive focus-within:ring-destructive",
className
)}
disabled={disabled}
id={id}
onChange={onChange}
placeholder={placeholder}
rows={rows}
value={value}
/>
{jsonError && (
<p className="ml-1 text-destructive text-xs">{jsonError}</p>
)}
</div>
);
}
25 changes: 17 additions & 8 deletions components/workflow/config/action-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getActionsByCategory,
getAllIntegrations,
} from "@/plugins";
import { RunActorConfigFields } from "@/plugins/apify/steps/run-actor/config";
import { ActionConfigRenderer } from "./action-config-renderer";
import { SchemaBuilder, type SchemaField } from "./schema-builder";

Expand Down Expand Up @@ -390,14 +391,22 @@ export function ActionConfig({
)}

{/* Plugin actions - declarative config fields */}
{pluginAction && !SYSTEM_ACTION_IDS.includes(actionType) && (
<ActionConfigRenderer
config={config}
disabled={disabled}
fields={pluginAction.configFields}
onUpdateConfig={handlePluginUpdateConfig}
/>
)}
{pluginAction &&
!SYSTEM_ACTION_IDS.includes(actionType) &&
(actionType === "apify/run-actor" ? (
<RunActorConfigFields
config={config}
disabled={disabled}
onUpdateConfig={handlePluginUpdateConfig}
/>
) : (
<ActionConfigRenderer
config={config}
disabled={disabled}
fields={pluginAction.configFields}
onUpdateConfig={handlePluginUpdateConfig}
/>
))}
</>
);
}
12 changes: 11 additions & 1 deletion lib/step-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* This registry enables dynamic step imports that are statically analyzable
* by the bundler. Each action type maps to its step importer function.
*
* Generated entries: 10
* Generated entries: 12
*/

import "server-only";
Expand Down Expand Up @@ -41,6 +41,14 @@ export const PLUGIN_STEP_IMPORTERS: Record<string, StepImporter> = {
importer: () => import("@/plugins/ai-gateway/steps/generate-image"),
stepFunction: "generateImageStep",
},
"apify/run-actor": {
importer: () => import("@/plugins/apify/steps/run-actor/step"),
stepFunction: "apifyRunActorStep",
},
"apify/scrape-single-url": {
importer: () => import("@/plugins/apify/steps/scrape-single-url/step"),
stepFunction: "scrapeSingleUrlStep",
},
"firecrawl/scrape": {
importer: () => import("@/plugins/firecrawl/steps/scrape"),
stepFunction: "firecrawlScrapeStep",
Expand Down Expand Up @@ -114,6 +122,8 @@ export const PLUGIN_STEP_IMPORTERS: Record<string, StepImporter> = {
export const ACTION_LABELS: Record<string, string> = {
"ai-gateway/generate-text": "Generate Text",
"ai-gateway/generate-image": "Generate Image",
"apify/run-actor": "Run Apify Actor",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's call it just "Run Actor" (Apify is already mentioned on the card)

"apify/scrape-single-url": "Scrape Single URL",
"firecrawl/scrape": "Scrape URL",
"firecrawl/search": "Search Web",
"linear/create-ticket": "Create Ticket",
Expand Down
10 changes: 10 additions & 0 deletions lib/steps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import type { generateImageStep } from "../../plugins/ai-gateway/steps/generate-image";
import type { generateTextStep } from "../../plugins/ai-gateway/steps/generate-text";
import type { apifyRunActorStep } from "../../plugins/apify/steps/run-actor/step";
import type { scrapeSingleUrlStep } from "../../plugins/apify/steps/scrape-single-url/step";
import type { firecrawlScrapeStep } from "../../plugins/firecrawl/steps/scrape";
import type { firecrawlSearchStep } from "../../plugins/firecrawl/steps/search";
import type { createTicketStep } from "../../plugins/linear/steps/create-ticket";
Expand Down Expand Up @@ -64,6 +66,14 @@ export const stepRegistry: Record<string, StepFunction> = {
(await import("../../plugins/firecrawl/steps/search")).firecrawlSearchStep(
input as Parameters<typeof firecrawlSearchStep>[0]
),
"Run Apify Actor": async (input) =>
(
await import("../../plugins/apify/steps/run-actor/step")
).apifyRunActorStep(input as Parameters<typeof apifyRunActorStep>[0]),
"Scrape Single URL": async (input) =>
(
await import("../../plugins/apify/steps/scrape-single-url/step")
).scrapeSingleUrlStep(input as Parameters<typeof scrapeSingleUrlStep>[0]),
};

// Helper to check if a step exists
Expand Down
3 changes: 2 additions & 1 deletion lib/types/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
* 2. Add a system integration to SYSTEM_INTEGRATION_TYPES in discover-plugins.ts
* 3. Run: pnpm discover-plugins
*
* Generated types: ai-gateway, database, firecrawl, linear, resend, slack, v0
* Generated types: ai-gateway, apify, database, firecrawl, linear, resend, slack, v0
*/

// Integration type union - plugins + system integrations
export type IntegrationType =
| "ai-gateway"
| "apify"
| "database"
| "firecrawl"
| "linear"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@vercel/speed-insights": "^1.2.0",
"@xyflow/react": "^12.9.2",
"ai": "^5.0.102",
"apify-client": "^2.20.0",
"better-auth": "^1.3.34",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
54 changes: 54 additions & 0 deletions plugins/apify/codegen/run-actor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Code generation template for Run Apify Actor action
* This template is used when exporting workflows to standalone Next.js projects
* It uses environment variables instead of integrationId
*/
export const runActorCodegenTemplate = `import { ApifyClient } from "apify-client";
export async function apifyRunActorStep(input: {
actorId: string;
actorInput?: string;
}) {
"use step";
const apiKey = process.env.APIFY_API_KEY;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use our standard APIFY_TOKEN name, or at least APIFY_API_TOKEN (we call it token, not key)

if (!apiKey) {
throw new Error("Apify API Token is not configured. Set APIFY_API_KEY environment variable.");
}
let parsedActorInput: Record<string, unknown> = {};
if (input.actorInput) {
try {
parsedActorInput = JSON.parse(input.actorInput);
} catch (err) {
throw new Error(\`Cannot parse Actor input: \${err instanceof Error ? err.message : String(err)}\`);
}
}
try {
const client = new ApifyClient({ token: apiKey });
const actorClient = client.actor(input.actorId);
// Run synchronously and wait for completion
const runData = await actorClient.call(parsedActorInput);
// Get dataset items
let datasetItems: unknown[] = [];
if (runData.defaultDatasetId) {
const dataset = await client
.dataset(runData.defaultDatasetId)
.listItems();
datasetItems = dataset.items;
}
return {
runId: runData.id || "unknown",
status: runData.status || "SUCCEEDED",
datasetId: runData.defaultDatasetId,
datasetItems,
};
} catch (error) {
throw new Error(\`Failed to run Actor: \${error instanceof Error ? error.message : String(error)}\`);
}
}`;
Loading
Loading