diff --git a/README.md b/README.md index d6a5e29e..5b0bfff9 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,16 @@ Create a `.env.local` file with the following: ```env # Database DATABASE_URL=postgresql://user:password@localhost:5432/workflow_builder +Key should be a 32-byte hex string (64 characters) +INTEGRATION_ENCRYPTION_KEY=your-encryption-key # Better Auth BETTER_AUTH_SECRET=your-secret-key BETTER_AUTH_URL=http://localhost:3000 -# AI Gateway (for AI workflow generation) -AI_GATEWAY_API_KEY=your-openai-api-key +# AI Gateway (for AI workflow generation, see more https://vercel.com/ai-gateway) +AI_GATEWAY_API_KEY=your-ai-gateway-api-key + ``` ### Installation @@ -81,6 +84,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started. - **AI Gateway**: Generate Text, Generate Image - **Blob**: Put Blob, List Blobs +- **Exa**: Search Web - **fal.ai**: Generate Image, Generate Video, Upscale Image, Remove Background, Image to Image - **Firecrawl**: Scrape URL, Search Web - **GitHub**: Create Issue, List Issues, Get Issue, Update Issue diff --git a/package.json b/package.json index b8d2c87f..6ad82774 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "discover-plugins": "tsx scripts/discover-plugins.ts", - "create-plugin": "tsx scripts/create-plugin.ts" + "create-plugin": "tsx scripts/create-plugin.ts", + "workflow:runs:web": "npx workflow inspect runs --web" }, "dependencies": { "@ai-sdk/provider": "^2.0.0", @@ -37,6 +38,7 @@ "clsx": "^2.1.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "exa-js": "^2.0.11", "jotai": "^2.15.1", "jszip": "^3.10.1", "lucide-react": "^0.552.0", diff --git a/plugins/exa/credentials.ts b/plugins/exa/credentials.ts new file mode 100644 index 00000000..f40ade58 --- /dev/null +++ b/plugins/exa/credentials.ts @@ -0,0 +1,4 @@ +export type ExaCredentials = { + EXA_API_KEY?: string; +}; + diff --git a/plugins/exa/icon.tsx b/plugins/exa/icon.tsx new file mode 100644 index 00000000..f642f426 --- /dev/null +++ b/plugins/exa/icon.tsx @@ -0,0 +1,31 @@ +export function ExaIcon({ className }: { className?: string }) { + return ( + + Exa + + + + + + ); +} diff --git a/plugins/exa/index.ts b/plugins/exa/index.ts new file mode 100644 index 00000000..84d8182d --- /dev/null +++ b/plugins/exa/index.ts @@ -0,0 +1,84 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { ExaIcon } from "./icon"; + +const exaPlugin: IntegrationPlugin = { + type: "exa", + label: "Exa", + description: + "Semantic web search API giving AI apps fast, relevant, up-to-date results", + + icon: ExaIcon, + + formFields: [ + { + id: "exaApiKey", + label: "API Key", + type: "password", + placeholder: "Your Exa API key", + configKey: "exaApiKey", + envVar: "EXA_API_KEY", + helpText: "Get your API key from ", + helpLink: { + text: "Exa Dashboard", + url: "https://dashboard.exa.ai/api-keys/", + }, + }, + ], + + testConfig: { + getTestFunction: async () => { + const { testExa } = await import("./test"); + return testExa; + }, + }, + + actions: [ + { + slug: "search", + label: "Search Web", + description: + "Perform semantic web search and retrieve relevant results with content", + category: "Exa", + stepFunction: "exaSearchStep", + stepImportPath: "search", + outputFields: [ + { field: "results", description: "Array of search results" }, + ], + configFields: [ + { + key: "query", + label: "Search Query", + type: "template-input", + placeholder: "Search query or {{NodeName.query}}", + example: "latest AI research papers", + required: true, + }, + { + key: "numResults", + label: "Number of Results", + type: "number", + placeholder: "10", + min: 1, + example: "10", + }, + { + key: "type", + label: "Search Type", + type: "select", + options: [ + { value: "auto", label: "Auto" }, + { value: "neural", label: "Neural" }, + { value: "keyword", label: "Keyword" }, + ], + defaultValue: "auto", + }, + ], + }, + ], +}; + +// Auto-register on import +registerIntegration(exaPlugin); + +export default exaPlugin; diff --git a/plugins/exa/steps/search.ts b/plugins/exa/steps/search.ts new file mode 100644 index 00000000..65e9b078 --- /dev/null +++ b/plugins/exa/steps/search.ts @@ -0,0 +1,129 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import type { ExaCredentials } from "../credentials"; + +const EXA_API_URL = "https://api.exa.ai"; + +type ExaSearchResponse = { + results: Array<{ + url: string; + id: string; + title: string | null; + publishedDate?: string; + author?: string; + text?: string; + }>; + autopromptString?: string; +}; + +type ExaErrorResponse = { + error?: string; + message?: string; +}; + +type SearchResult = + | { + success: true; + results: Array<{ + url: string; + title: string | null; + publishedDate?: string; + author?: string; + text?: string; + }>; + } + | { success: false; error: string }; + +export type SearchCoreInput = { + query: string; + numResults?: number; + type?: "auto" | "neural" | "keyword"; +}; + +export type ExaSearchInput = StepInput & + SearchCoreInput & { + integrationId?: string; + }; + +/** + * Core logic - portable between app and export + */ +async function stepHandler( + input: SearchCoreInput, + credentials: ExaCredentials +): Promise { + const apiKey = credentials.EXA_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "EXA_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + try { + const response = await fetch(`${EXA_API_URL}/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + query: input.query, + numResults: input.numResults ? Number(input.numResults) : 10, + type: input.type || "auto", + }), + }); + + if (!response.ok) { + const errorData = (await response.json()) as ExaErrorResponse; + return { + success: false, + error: + errorData.error || + errorData.message || + `HTTP ${response.status}: Search failed`, + }; + } + + const data = (await response.json()) as ExaSearchResponse; + + return { + success: true, + results: data.results.map((r) => ({ + url: r.url, + title: r.title, + publishedDate: r.publishedDate, + author: r.author, + text: r.text, + })), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: `Failed to search: ${message}`, + }; + } +} + +/** + * App entry point - fetches credentials and wraps with logging + */ +export async function exaSearchStep( + input: ExaSearchInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +// Export marker for codegen auto-generation +export const _integrationType = "exa"; diff --git a/plugins/exa/test.ts b/plugins/exa/test.ts new file mode 100644 index 00000000..093c2a16 --- /dev/null +++ b/plugins/exa/test.ts @@ -0,0 +1,42 @@ +export async function testExa(credentials: Record) { + try { + const apiKey = credentials.EXA_API_KEY; + + if (!apiKey) { + return { + success: false, + error: "EXA_API_KEY is required", + }; + } + + // Use a minimal search request to validate the API key + const response = await fetch("https://api.exa.ai/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + query: "test", + numResults: 1, + type: "keyword", + }), + }); + + if (response.ok) { + return { success: true }; + } + + if (response.status === 401) { + return { success: false, error: "Invalid API key" }; + } + + const error = await response.text(); + return { success: false, error: error || `API error: HTTP ${response.status}` }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/plugins/index.ts b/plugins/index.ts index c2b41249..fce9e89e 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -16,6 +16,7 @@ import "./ai-gateway"; import "./blob"; +import "./exa"; import "./fal"; import "./firecrawl"; import "./github"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c09e4d01..483f2a7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@opentelemetry/api@1.9.0)(kysely@0.28.8)(postgres@3.4.7) + exa-js: + specifier: ^2.0.11 + version: 2.0.11(ws@8.18.3) jotai: specifier: ^2.15.1 version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0) @@ -2864,6 +2867,9 @@ packages: typescript: optional: true + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2984,6 +2990,10 @@ packages: dompurify@3.1.7: resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==} + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -3200,6 +3210,9 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + exa-js@2.0.11: + resolution: {integrity: sha512-xMZjtZQ9dqhHCFspWhq9mQFF7H+hJw1yQE2wKS99ZuzO3JrqzqxbPK60BjNhkiMqiD/n+06O1fuLZs/HAqeaEg==} + execa@9.6.0: resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} engines: {node: ^18.19.0 || >=20.5.0} @@ -3873,6 +3886,18 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + openai@5.23.2: + resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openai@6.8.1: resolution: {integrity: sha512-ACifslrVgf+maMz9vqwMP4+v9qvx5Yzssydizks8n+YUJ6YwUoxj51sKRQ8HYMfR6wgKLSIlaI108ZwCk+8yig==} hasBin: true @@ -7686,6 +7711,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7786,6 +7817,8 @@ snapshots: dompurify@3.1.7: {} + dotenv@16.4.7: {} + dotenv@17.2.3: {} drizzle-kit@0.31.6: @@ -7949,6 +7982,17 @@ snapshots: eventsource-parser@3.0.6: {} + exa-js@2.0.11(ws@8.18.3): + dependencies: + cross-fetch: 4.1.0 + dotenv: 16.4.7 + openai: 5.23.2(ws@8.18.3)(zod@3.25.76) + zod: 3.25.76 + zod-to-json-schema: 3.25.0(zod@3.25.76) + transitivePeerDependencies: + - encoding + - ws + execa@9.6.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -8542,6 +8586,11 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + openai@5.23.2(ws@8.18.3)(zod@3.25.76): + optionalDependencies: + ws: 8.18.3 + zod: 3.25.76 + openai@6.8.1(ws@8.18.3)(zod@4.1.12): optionalDependencies: ws: 8.18.3