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
23 changes: 23 additions & 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 Actor, Scrape Single URL
- **Blob**: Put Blob, List Blobs
- **fal.ai**: Generate Image, Generate Video, Upscale Image, Remove Background, Image to Image
- **Firecrawl**: Scrape URL, Search Web
Expand Down Expand Up @@ -268,6 +269,28 @@ const searchResult = await firecrawlSearchStep({
});
```

### Apify (Web Scraping)

```typescript
import { apifyRunActorStep } from "@/lib/apify/steps/run-actor/step";
import { scrapeSingleUrlStep } from "@/lib/apify/steps/scrape-single-url/step";

// Scrape a URL
const scrapeResult = await scrapeSingleUrlStep({
url: "https://example.com",
crawlerType: "playwright:adaptive",
});

// Run an Actor from Apify Store
const searchMapsResults = await apifyRunActorStep({
actorId: "compass/crawler-google-places",
actorInput: {
searchStringsArray: [ "restaurants in San Francisco" ]
},
});
```


## Tech Stack

- **Framework**: Next.js 16 with React 19
Expand Down
151 changes: 151 additions & 0 deletions components/ui/template-badge-json.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"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;
}

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

// Ensure that parsable values (not object) throws
if (!/^\s*\{[\s\S]*\}\s*$/.test(value)) {
setJsonError("Value must be a JSON object");
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}
/>
))}
</>
);
}
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 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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
60 changes: 60 additions & 0 deletions plugins/apify/codegen/run-actor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Code generation template for Run 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 | Record<string, unknown> | null;
}) {
"use step";

const apiKey = process.env.APIFY_API_TOKEN;

if (!apiKey) {
throw new Error("Apify API Token is not configured. Set APIFY_API_TOKEN environment variable.");
}

let parsedActorInput: Record<string, unknown> = {};
if (input.actorInput) {
// If it's already an object, use it directly
if (typeof input.actorInput === "object" && !Array.isArray(input.actorInput)) {
parsedActorInput = input.actorInput;
} else if (typeof input.actorInput === "string") {
// If it's a string, parse it
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,
data: datasetItems,
};
} catch (error) {
throw new Error(\`Failed to run Actor: \${error instanceof Error ? error.message : String(error)}\`);
}
}`;
68 changes: 68 additions & 0 deletions plugins/apify/codegen/scrape-single-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Code generation template for Scrape Single URL action
* This template is used when exporting workflows to standalone Next.js projects
* It uses environment variables instead of integrationId
*/
export const scrapeSingleUrlCodegenTemplate = `import { ApifyClient } from "apify-client";

export async function scrapeSingleUrlStep(input: {
url: string;
crawlerType?: string;
}) {
"use step";

const apiKey = process.env.APIFY_API_TOKEN;

if (!apiKey) {
throw new Error("Apify API Token is not configured. Set APIFY_API_TOKEN environment variable.");
}

if (!input.url) {
throw new Error("URL is required.");
}

try {
const client = new ApifyClient({ token: apiKey });
const actorClient = client.actor("apify/website-content-crawler");
const crawlerType = input.crawlerType || "playwright:adaptive";

// Prepare actor input
const actorInput = {
startUrls: [{ url: input.url }],
crawlerType,
maxCrawlDepth: 0,
maxCrawlPages: 1,
maxResults: 1,
proxyConfiguration: {
useApifyProxy: true,
},
removeCookieWarnings: true,
saveMarkdown: true,
};

// Run synchronously and wait for completion
const runData = await actorClient.call(actorInput);

// Get dataset items
let markdown: string | undefined;
if (runData.defaultDatasetId) {
const datasetItems = await client
.dataset(runData.defaultDatasetId)
.listItems();

// Extract markdown from the first item
if (datasetItems.items && datasetItems.items.length > 0) {
const firstItem = datasetItems.items[0] as Record<string, unknown>;
markdown = firstItem.markdown as string;
}
}

return {
runId: runData.id || "unknown",
status: runData.status || "SUCCEEDED",
markdown,
};
} catch (error) {
throw new Error(\`Failed to scrape URL: \${error instanceof Error ? error.message : String(error)}\`);
}
}`;
Loading