diff --git a/README.md b/README.md index c15185d8..452f75e9 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started. - **AI Gateway**: Generate Text, Generate Image - **Blob**: Put Blob, List Blobs +- **ElevenLabs**: Text to Speech, Get Voices, Speech to Text, Generate Sound Effect - **Firecrawl**: Scrape URL, Search Web - **GitHub**: Create Issue, List Issues, Get Issue, Update Issue - **Linear**: Create Ticket, Find Issues diff --git a/plugins/elevenlabs/credentials.ts b/plugins/elevenlabs/credentials.ts new file mode 100644 index 00000000..a8add2f0 --- /dev/null +++ b/plugins/elevenlabs/credentials.ts @@ -0,0 +1,3 @@ +export type ElevenlabsCredentials = { + ELEVENLABS_API_KEY?: string; +}; diff --git a/plugins/elevenlabs/icon.tsx b/plugins/elevenlabs/icon.tsx new file mode 100644 index 00000000..960d9403 --- /dev/null +++ b/plugins/elevenlabs/icon.tsx @@ -0,0 +1,14 @@ +export function ElevenlabsIcon({ className }: { className?: string }) { + return ( + + ElevenLabs + + + ); +} diff --git a/plugins/elevenlabs/index.ts b/plugins/elevenlabs/index.ts new file mode 100644 index 00000000..5940d818 --- /dev/null +++ b/plugins/elevenlabs/index.ts @@ -0,0 +1,204 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { ElevenlabsIcon } from "./icon"; + +const elevenlabsPlugin: IntegrationPlugin = { + type: "elevenlabs", + label: "ElevenLabs", + description: "AI voice generation and speech synthesis", + + icon: ElevenlabsIcon, + + formFields: [ + { + id: "apiKey", + label: "API Key", + type: "password", + placeholder: "Your ElevenLabs API key", + configKey: "apiKey", + envVar: "ELEVENLABS_API_KEY", + helpText: "Get your API key from ", + helpLink: { + text: "elevenlabs.io/app/settings/api-keys", + url: "https://elevenlabs.io/app/settings/api-keys", + }, + }, + ], + + testConfig: { + getTestFunction: async () => { + const { testElevenlabs } = await import("./test"); + return testElevenlabs; + }, + }, + + actions: [ + { + slug: "text-to-speech", + label: "Text to Speech", + description: "Convert text to speech using ElevenLabs voices", + category: "ElevenLabs", + stepFunction: "textToSpeechStep", + stepImportPath: "text-to-speech", + outputFields: [ + { field: "audioBase64", description: "Base64-encoded audio data" }, + { field: "contentType", description: "Audio content type (e.g., audio/mpeg)" }, + ], + configFields: [ + { + key: "voiceId", + label: "Voice ID", + type: "template-input", + placeholder: "21m00Tcm4TlvDq8ikWAM (Rachel)", + example: "21m00Tcm4TlvDq8ikWAM", + required: true, + }, + { + key: "text", + label: "Text", + type: "template-textarea", + placeholder: "Enter the text to convert to speech...", + rows: 4, + example: "Hello, welcome to my application!", + required: true, + }, + { + key: "modelId", + label: "Model", + type: "select", + defaultValue: "eleven_multilingual_v2", + options: [ + { value: "eleven_multilingual_v2", label: "Multilingual v2 (Best Quality)" }, + { value: "eleven_turbo_v2_5", label: "Turbo v2.5 (Low Latency)" }, + { value: "eleven_turbo_v2", label: "Turbo v2 (Legacy)" }, + { value: "eleven_monolingual_v1", label: "Monolingual v1 (English)" }, + { value: "eleven_flash_v2_5", label: "Flash v2.5 (Fastest)" }, + { value: "eleven_flash_v2", label: "Flash v2 (Legacy Fast)" }, + ], + }, + { + type: "group", + label: "Voice Settings", + fields: [ + { + key: "stability", + label: "Stability (0-1)", + type: "number", + placeholder: "0.5", + min: 0, + }, + { + key: "similarityBoost", + label: "Similarity Boost (0-1)", + type: "number", + placeholder: "0.75", + min: 0, + }, + { + key: "style", + label: "Style (0-1)", + type: "number", + placeholder: "0", + min: 0, + }, + ], + }, + { + type: "group", + label: "Output Settings", + fields: [ + { + key: "outputFormat", + label: "Output Format", + type: "select", + defaultValue: "mp3_44100_128", + options: [ + { value: "mp3_44100_128", label: "MP3 44.1kHz 128kbps" }, + { value: "mp3_44100_192", label: "MP3 44.1kHz 192kbps" }, + { value: "pcm_16000", label: "PCM 16kHz" }, + { value: "pcm_22050", label: "PCM 22.05kHz" }, + { value: "pcm_24000", label: "PCM 24kHz" }, + { value: "pcm_44100", label: "PCM 44.1kHz" }, + ], + }, + ], + }, + ], + }, + { + slug: "get-voices", + label: "Get Voices", + description: "List all available voices in your ElevenLabs account", + category: "ElevenLabs", + stepFunction: "getVoicesStep", + stepImportPath: "get-voices", + outputFields: [ + { field: "voices", description: "Array of available voices with their IDs and names" }, + ], + configFields: [], + }, + { + slug: "speech-to-text", + label: "Speech to Text", + description: "Transcribe audio to text using ElevenLabs", + category: "ElevenLabs", + stepFunction: "speechToTextStep", + stepImportPath: "speech-to-text", + outputFields: [ + { field: "text", description: "Transcribed text from the audio" }, + { field: "language", description: "Detected language code" }, + ], + configFields: [ + { + key: "audioUrl", + label: "Audio URL", + type: "template-input", + placeholder: "https://example.com/audio.mp3 or {{NodeName.url}}", + example: "https://example.com/recording.mp3", + required: true, + }, + { + key: "languageCode", + label: "Language Code (Optional)", + type: "template-input", + placeholder: "en (auto-detect if empty)", + example: "en", + }, + ], + }, + { + slug: "sound-effects", + label: "Generate Sound Effect", + description: "Generate sound effects from text descriptions", + category: "ElevenLabs", + stepFunction: "soundEffectsStep", + stepImportPath: "sound-effects", + outputFields: [ + { field: "audioBase64", description: "Base64-encoded audio data" }, + { field: "contentType", description: "Audio content type" }, + ], + configFields: [ + { + key: "text", + label: "Description", + type: "template-textarea", + placeholder: "Describe the sound effect you want to generate...", + rows: 3, + example: "A gentle rain falling on a rooftop with occasional thunder in the distance", + required: true, + }, + { + key: "durationSeconds", + label: "Duration (seconds, 0.5-22)", + type: "number", + placeholder: "Auto", + min: 0, + }, + ], + }, + ], +}; + +registerIntegration(elevenlabsPlugin); + +export default elevenlabsPlugin; diff --git a/plugins/elevenlabs/steps/get-voices.ts b/plugins/elevenlabs/steps/get-voices.ts new file mode 100644 index 00000000..3e4cdccf --- /dev/null +++ b/plugins/elevenlabs/steps/get-voices.ts @@ -0,0 +1,118 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import type { ElevenlabsCredentials } from "../credentials"; + +const ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1"; + +type Voice = { + voice_id: string; + name: string; + category: string; + description: string | null; + labels: Record; + preview_url: string | null; +}; + +type VoiceInfo = { + voiceId: string; + name: string; + category: string; + description: string | null; + labels: Record; + previewUrl: string | null; +}; + +type GetVoicesResult = + | { success: true; voices: VoiceInfo[] } + | { success: false; error: string }; + +export type GetVoicesCoreInput = Record; + +export type GetVoicesInput = StepInput & + GetVoicesCoreInput & { + integrationId?: string; + }; + +/** + * Core logic - portable between app and export + */ +async function stepHandler( + _input: GetVoicesCoreInput, + credentials: ElevenlabsCredentials +): Promise { + const apiKey = credentials.ELEVENLABS_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "ELEVENLABS_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + try { + const response = await fetch(`${ELEVENLABS_API_URL}/voices`, { + method: "GET", + headers: { + "xi-api-key": apiKey, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage: string; + try { + const errorData = JSON.parse(errorText) as { detail?: { message?: string } }; + errorMessage = + errorData.detail?.message || `HTTP ${response.status}: ${response.statusText}`; + } catch { + errorMessage = `HTTP ${response.status}: ${response.statusText}`; + } + return { + success: false, + error: errorMessage, + }; + } + + const data = (await response.json()) as { voices: Voice[] }; + + const voices: VoiceInfo[] = data.voices.map((voice) => ({ + voiceId: voice.voice_id, + name: voice.name, + category: voice.category, + description: voice.description, + labels: voice.labels, + previewUrl: voice.preview_url, + })); + + return { + success: true, + voices, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: `Failed to fetch voices: ${message}`, + }; + } +} + +/** + * App entry point - fetches credentials and wraps with logging + */ +export async function getVoicesStep( + input: GetVoicesInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler({}, credentials)); +} + +export const _integrationType = "elevenlabs"; diff --git a/plugins/elevenlabs/steps/sound-effects.ts b/plugins/elevenlabs/steps/sound-effects.ts new file mode 100644 index 00000000..c1850e51 --- /dev/null +++ b/plugins/elevenlabs/steps/sound-effects.ts @@ -0,0 +1,113 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import type { ElevenlabsCredentials } from "../credentials"; + +const ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1"; + +type SoundEffectsResult = + | { success: true; audioBase64: string; contentType: string } + | { success: false; error: string }; + +export type SoundEffectsCoreInput = { + text: string; + durationSeconds?: number; +}; + +export type SoundEffectsInput = StepInput & + SoundEffectsCoreInput & { + integrationId?: string; + }; + +/** + * Core logic - portable between app and export + */ +async function stepHandler( + input: SoundEffectsCoreInput, + credentials: ElevenlabsCredentials +): Promise { + const apiKey = credentials.ELEVENLABS_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "ELEVENLABS_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + if (!input.text || input.text.trim() === "") { + return { + success: false, + error: "Text description is required for sound effect generation", + }; + } + + try { + const requestBody: Record = { + text: input.text, + }; + + if (input.durationSeconds !== undefined) { + requestBody.duration_seconds = input.durationSeconds; + } + + const response = await fetch(`${ELEVENLABS_API_URL}/sound-generation`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "xi-api-key": apiKey, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage: string; + try { + const errorData = JSON.parse(errorText) as { detail?: { message?: string } }; + errorMessage = + errorData.detail?.message || `HTTP ${response.status}: ${response.statusText}`; + } catch { + errorMessage = `HTTP ${response.status}: ${response.statusText}`; + } + return { + success: false, + error: errorMessage, + }; + } + + const audioBuffer = await response.arrayBuffer(); + const audioBase64 = Buffer.from(audioBuffer).toString("base64"); + + return { + success: true, + audioBase64, + contentType: "audio/mpeg", + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: `Failed to generate sound effect: ${message}`, + }; + } +} + +/** + * App entry point - fetches credentials and wraps with logging + */ +export async function soundEffectsStep( + input: SoundEffectsInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "elevenlabs"; diff --git a/plugins/elevenlabs/steps/speech-to-text.ts b/plugins/elevenlabs/steps/speech-to-text.ts new file mode 100644 index 00000000..083286d0 --- /dev/null +++ b/plugins/elevenlabs/steps/speech-to-text.ts @@ -0,0 +1,140 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import type { ElevenlabsCredentials } from "../credentials"; + +const ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1"; + +type SpeechToTextResult = + | { success: true; text: string; language: string } + | { success: false; error: string }; + +export type SpeechToTextCoreInput = { + audioUrl: string; + languageCode?: string; +}; + +export type SpeechToTextInput = StepInput & + SpeechToTextCoreInput & { + integrationId?: string; + }; + +/** + * Core logic - portable between app and export + */ +async function stepHandler( + input: SpeechToTextCoreInput, + credentials: ElevenlabsCredentials +): Promise { + const apiKey = credentials.ELEVENLABS_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "ELEVENLABS_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + if (!input.audioUrl || input.audioUrl.trim() === "") { + return { + success: false, + error: "Audio URL is required for speech-to-text conversion", + }; + } + + try { + // First, fetch the audio file from the URL + const audioResponse = await fetch(input.audioUrl); + + if (!audioResponse.ok) { + return { + success: false, + error: `Failed to fetch audio from URL: HTTP ${audioResponse.status}`, + }; + } + + const audioBlob = await audioResponse.blob(); + const contentType = audioResponse.headers.get("content-type") || "audio/mpeg"; + + // Determine file extension from content type + let extension = "mp3"; + if (contentType.includes("wav")) { + extension = "wav"; + } else if (contentType.includes("ogg")) { + extension = "ogg"; + } else if (contentType.includes("flac")) { + extension = "flac"; + } else if (contentType.includes("webm")) { + extension = "webm"; + } + + // Create FormData for the multipart request + const formData = new FormData(); + formData.append("file", audioBlob, `audio.${extension}`); + formData.append("model_id", "scribe_v1"); + + if (input.languageCode) { + formData.append("language_code", input.languageCode); + } + + const response = await fetch(`${ELEVENLABS_API_URL}/speech-to-text`, { + method: "POST", + headers: { + "xi-api-key": apiKey, + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage: string; + try { + const errorData = JSON.parse(errorText) as { detail?: { message?: string } }; + errorMessage = + errorData.detail?.message || `HTTP ${response.status}: ${response.statusText}`; + } catch { + errorMessage = `HTTP ${response.status}: ${response.statusText}`; + } + return { + success: false, + error: errorMessage, + }; + } + + const data = (await response.json()) as { + text: string; + language_code: string; + }; + + return { + success: true, + text: data.text, + language: data.language_code, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: `Failed to transcribe audio: ${message}`, + }; + } +} + +/** + * App entry point - fetches credentials and wraps with logging + */ +export async function speechToTextStep( + input: SpeechToTextInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "elevenlabs"; diff --git a/plugins/elevenlabs/steps/text-to-speech.ts b/plugins/elevenlabs/steps/text-to-speech.ts new file mode 100644 index 00000000..01de3b90 --- /dev/null +++ b/plugins/elevenlabs/steps/text-to-speech.ts @@ -0,0 +1,145 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import type { ElevenlabsCredentials } from "../credentials"; + +const ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1"; + +type TextToSpeechResult = + | { success: true; audioBase64: string; contentType: string } + | { success: false; error: string }; + +export type TextToSpeechCoreInput = { + voiceId: string; + text: string; + modelId?: string; + stability?: number; + similarityBoost?: number; + style?: number; + outputFormat?: string; +}; + +export type TextToSpeechInput = StepInput & + TextToSpeechCoreInput & { + integrationId?: string; + }; + +/** + * Core logic - portable between app and export + */ +async function stepHandler( + input: TextToSpeechCoreInput, + credentials: ElevenlabsCredentials +): Promise { + const apiKey = credentials.ELEVENLABS_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "ELEVENLABS_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + if (!input.voiceId) { + return { + success: false, + error: "Voice ID is required", + }; + } + + if (!input.text || input.text.trim() === "") { + return { + success: false, + error: "Text is required for text-to-speech conversion", + }; + } + + const outputFormat = input.outputFormat || "mp3_44100_128"; + + try { + const voiceSettings: Record = {}; + + if (input.stability !== undefined) { + voiceSettings.stability = input.stability; + } + if (input.similarityBoost !== undefined) { + voiceSettings.similarity_boost = input.similarityBoost; + } + if (input.style !== undefined) { + voiceSettings.style = input.style; + } + + const requestBody: Record = { + text: input.text, + model_id: input.modelId || "eleven_multilingual_v2", + }; + + if (Object.keys(voiceSettings).length > 0) { + requestBody.voice_settings = voiceSettings; + } + + const response = await fetch( + `${ELEVENLABS_API_URL}/text-to-speech/${input.voiceId}?output_format=${outputFormat}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "xi-api-key": apiKey, + }, + body: JSON.stringify(requestBody), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage: string; + try { + const errorData = JSON.parse(errorText) as { detail?: { message?: string } }; + errorMessage = + errorData.detail?.message || `HTTP ${response.status}: ${response.statusText}`; + } catch { + errorMessage = `HTTP ${response.status}: ${response.statusText}`; + } + return { + success: false, + error: errorMessage, + }; + } + + const audioBuffer = await response.arrayBuffer(); + const audioBase64 = Buffer.from(audioBuffer).toString("base64"); + + const contentType = outputFormat.startsWith("mp3") ? "audio/mpeg" : "audio/wav"; + + return { + success: true, + audioBase64, + contentType, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: `Failed to generate speech: ${message}`, + }; + } +} + +/** + * App entry point - fetches credentials and wraps with logging + */ +export async function textToSpeechStep( + input: TextToSpeechInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "elevenlabs"; diff --git a/plugins/elevenlabs/test.ts b/plugins/elevenlabs/test.ts new file mode 100644 index 00000000..fe10e31e --- /dev/null +++ b/plugins/elevenlabs/test.ts @@ -0,0 +1,42 @@ +const ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1"; + +export async function testElevenlabs(credentials: Record) { + try { + const apiKey = credentials.ELEVENLABS_API_KEY; + + if (!apiKey) { + return { + success: false, + error: "ELEVENLABS_API_KEY is required", + }; + } + + // Validate API key by fetching user info (lightweight read-only endpoint) + const response = await fetch(`${ELEVENLABS_API_URL}/user`, { + method: "GET", + headers: { + "xi-api-key": apiKey, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + return { + success: false, + error: "Invalid API key. Please check your ElevenLabs API key.", + }; + } + return { + success: false, + error: `API validation failed: HTTP ${response.status}`, + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/plugins/index.ts b/plugins/index.ts index af795cb5..dc7b4036 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -13,11 +13,12 @@ * 1. Delete the plugin directory * 2. Run: pnpm discover-plugins (or it runs automatically on build) * - * Discovered plugins: ai-gateway, blob, firecrawl, github, linear, resend, slack, superagent, v0 + * Discovered plugins: ai-gateway, blob, elevenlabs, firecrawl, github, linear, resend, slack, superagent, v0 */ import "./ai-gateway"; import "./blob"; +import "./elevenlabs"; import "./firecrawl"; import "./github"; import "./linear";