first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,102 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { baseAnalyze } from '../../helpers/baseAnalyze';
import { modelRLC } from '../descriptions';
const properties: INodeProperties[] = [
modelRLC('audioModelSearch'),
{
displayName: 'Text Input',
name: 'text',
type: 'string',
placeholder: "e.g. What's in this audio?",
default: "What's in this audio?",
typeOptions: {
rows: 2,
},
},
{
displayName: 'Input Type',
name: 'inputType',
type: 'options',
default: 'url',
options: [
{
name: 'Audio URL(s)',
value: 'url',
},
{
name: 'Binary File(s)',
value: 'binary',
},
],
},
{
displayName: 'URL(s)',
name: 'audioUrls',
type: 'string',
placeholder: 'e.g. https://example.com/audio.mp3',
description: 'URL(s) of the audio(s) to analyze, multiple URLs can be added separated by comma',
default: '',
displayOptions: {
show: {
inputType: ['url'],
},
},
},
{
displayName: 'Input Data Field Name(s)',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
hint: 'The name of the input field containing the binary file data to be processed',
description:
'Name of the binary field(s) which contains the audio(s), seperate multiple field names with commas',
displayOptions: {
show: {
inputType: ['binary'],
},
},
},
{
displayName: 'Simplify Output',
name: 'simplify',
type: 'boolean',
default: true,
description: 'Whether to simplify the response or not',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Length of Description (Max Tokens)',
description: 'Fewer tokens will result in shorter, less detailed audio description',
name: 'maxOutputTokens',
type: 'number',
default: 300,
typeOptions: {
minValue: 1,
},
},
],
},
];
const displayOptions = {
show: {
operation: ['analyze'],
resource: ['audio'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
return await baseAnalyze.call(this, i, 'audioUrls', 'audio/mpeg');
}
@@ -0,0 +1,37 @@
import type { INodeProperties } from 'n8n-workflow';
import * as analyze from './analyze.operation';
import * as transcribe from './transcribe.operation';
export { analyze, transcribe };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Analyze Audio',
value: 'analyze',
action: 'Analyze audio',
description: 'Take in audio and answer questions about it',
},
{
name: 'Transcribe a Recording',
value: 'transcribe',
action: 'Transcribe a recording',
description: 'Transcribes audio into the text',
},
],
default: 'transcribe',
displayOptions: {
show: {
resource: ['audio'],
},
},
},
...analyze.description,
...transcribe.description,
];
@@ -0,0 +1,185 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import type {
Content,
GenerateContentRequest,
GenerateContentResponse,
} from '../../helpers/interfaces';
import { downloadFile, uploadFile } from '../../helpers/utils';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
const properties: INodeProperties[] = [
modelRLC('audioModelSearch'),
{
displayName: 'Input Type',
name: 'inputType',
type: 'options',
default: 'url',
options: [
{
name: 'Audio URL(s)',
value: 'url',
},
{
name: 'Binary File(s)',
value: 'binary',
},
],
},
{
displayName: 'URL(s)',
name: 'audioUrls',
type: 'string',
placeholder: 'e.g. https://example.com/audio.mp3',
description:
'URL(s) of the audio(s) to transcribe, multiple URLs can be added separated by comma',
default: '',
displayOptions: {
show: {
inputType: ['url'],
},
},
},
{
displayName: 'Input Data Field Name(s)',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
hint: 'The name of the input field containing the binary file data to be processed',
description:
'Name of the binary field(s) which contains the audio(s), seperate multiple field names with commas',
displayOptions: {
show: {
inputType: ['binary'],
},
},
},
{
displayName: 'Simplify Output',
name: 'simplify',
type: 'boolean',
default: true,
description: 'Whether to simplify the response or not',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
options: [
{
displayName: 'Start Time',
name: 'startTime',
type: 'string',
default: '',
description: 'The start time of the audio in MM:SS or HH:MM:SS format',
placeholder: 'e.g. 00:15',
},
{
displayName: 'End Time',
name: 'endTime',
type: 'string',
default: '',
description: 'The end time of the audio in MM:SS or HH:MM:SS format',
placeholder: 'e.g. 02:15',
},
],
},
];
const displayOptions = {
show: {
operation: ['transcribe'],
resource: ['audio'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
const inputType = this.getNodeParameter('inputType', i, 'url') as string;
const simplify = this.getNodeParameter('simplify', i, true) as boolean;
const options = this.getNodeParameter('options', i, {});
let contents: Content[];
if (inputType === 'url') {
const urls = this.getNodeParameter('audioUrls', i, '') as string;
const filesDataPromises = urls
.split(',')
.map((url) => url.trim())
.filter((url) => url)
.map(async (url) => {
if (url.startsWith('https://generativelanguage.googleapis.com')) {
const { mimeType } = (await apiRequest.call(this, 'GET', '', {
option: { url },
})) as { mimeType: string };
return { fileUri: url, mimeType };
} else {
const { fileContent, mimeType } = await downloadFile.call(this, url, 'audio/mpeg');
return await uploadFile.call(this, fileContent, mimeType);
}
});
const filesData = await Promise.all(filesDataPromises);
contents = [
{
role: 'user',
parts: filesData.map((fileData) => ({
fileData,
})),
},
];
} else {
const binaryPropertyNames = this.getNodeParameter('binaryPropertyName', i, 'data');
const promises = binaryPropertyNames
.split(',')
.map((binaryPropertyName) => binaryPropertyName.trim())
.filter((binaryPropertyName) => binaryPropertyName)
.map(async (binaryPropertyName) => {
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
return await uploadFile.call(this, buffer, binaryData.mimeType);
});
const filesData = await Promise.all(promises);
contents = [
{
role: 'user',
parts: filesData.map((fileData) => ({
fileData,
})),
},
];
}
const text = `Generate a transcript of the speech${
options.startTime ? ` from ${options.startTime as string}` : ''
}${options.endTime ? ` to ${options.endTime as string}` : ''}`;
contents[0].parts.push({ text });
const body: GenerateContentRequest = {
contents,
};
const response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, {
body,
})) as GenerateContentResponse;
if (simplify) {
return response.candidates.map((candidate) => ({
json: candidate,
pairedItem: { item: i },
}));
}
return [
{
json: { ...response },
pairedItem: { item: i },
},
];
}
@@ -0,0 +1,26 @@
import type { INodeProperties } from 'n8n-workflow';
export const modelRLC = (searchListMethod: string): INodeProperties => ({
displayName: 'Model',
name: 'modelId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod,
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
placeholder: 'e.g. models/gemini-2.5-flash',
},
],
});
@@ -0,0 +1,103 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { baseAnalyze } from '../../helpers/baseAnalyze';
import { modelRLC } from '../descriptions';
const properties: INodeProperties[] = [
modelRLC('modelSearch'),
{
displayName: 'Text Input',
name: 'text',
type: 'string',
placeholder: "e.g. What's in this document?",
default: "What's in this document?",
typeOptions: {
rows: 2,
},
},
{
displayName: 'Input Type',
name: 'inputType',
type: 'options',
default: 'url',
options: [
{
name: 'Document URL(s)',
value: 'url',
},
{
name: 'Binary File(s)',
value: 'binary',
},
],
},
{
displayName: 'URL(s)',
name: 'documentUrls',
type: 'string',
placeholder: 'e.g. https://example.com/document.pdf',
description:
'URL(s) of the document(s) to analyze, multiple URLs can be added separated by comma',
default: '',
displayOptions: {
show: {
inputType: ['url'],
},
},
},
{
displayName: 'Input Data Field Name(s)',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
hint: 'The name of the input field containing the binary file data to be processed',
description:
'Name of the binary field(s) which contains the document(s), seperate multiple field names with commas',
displayOptions: {
show: {
inputType: ['binary'],
},
},
},
{
displayName: 'Simplify Output',
name: 'simplify',
type: 'boolean',
default: true,
description: 'Whether to simplify the response or not',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Length of Description (Max Tokens)',
description: 'Fewer tokens will result in shorter, less detailed document description',
name: 'maxOutputTokens',
type: 'number',
default: 300,
typeOptions: {
minValue: 1,
},
},
],
},
];
const displayOptions = {
show: {
operation: ['analyze'],
resource: ['document'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
return await baseAnalyze.call(this, i, 'documentUrls', 'application/pdf');
}
@@ -0,0 +1,29 @@
import type { INodeProperties } from 'n8n-workflow';
import * as analyze from './analyze.operation';
export { analyze };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Analyze Document',
value: 'analyze',
action: 'Analyze document',
description: 'Take in documents and answer questions about them',
},
],
default: 'analyze',
displayOptions: {
show: {
resource: ['document'],
},
},
},
...analyze.description,
];
@@ -0,0 +1,29 @@
import type { INodeProperties } from 'n8n-workflow';
import * as upload from './upload.operation';
export { upload };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Upload Media File',
value: 'upload',
action: 'Upload a media file',
description: 'Upload a file to the Google Gemini API for later use',
},
],
default: 'upload',
displayOptions: {
show: {
resource: ['file'],
},
},
},
...upload.description,
];
@@ -0,0 +1,78 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { transferFile } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'Input Type',
name: 'inputType',
type: 'options',
default: 'url',
options: [
{
name: 'File URL',
value: 'url',
},
{
name: 'Binary File',
value: 'binary',
},
],
},
{
displayName: 'URL',
name: 'fileUrl',
type: 'string',
placeholder: 'e.g. https://example.com/file.pdf',
description: 'URL of the file to upload',
default: '',
displayOptions: {
show: {
inputType: ['url'],
},
},
},
{
displayName: 'Input Data Field Name',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
hint: 'The name of the input field containing the binary file data to be processed',
description: 'Name of the binary property which contains the file',
displayOptions: {
show: {
inputType: ['binary'],
},
},
},
];
const displayOptions = {
show: {
operation: ['upload'],
resource: ['file'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const inputType = this.getNodeParameter('inputType', i, 'url') as string;
let fileUrl: string | undefined;
if (inputType === 'url') {
fileUrl = this.getNodeParameter('fileUrl', i, '') as string;
}
const response = await transferFile.call(this, i, fileUrl, 'application/octet-stream');
return [
{
json: response,
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,39 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { createFileSearchStore } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'Display Name',
name: 'displayName',
type: 'string',
placeholder: 'e.g. My File Search Store',
description: 'A human-readable name for the File Search store',
default: '',
required: true,
},
];
const displayOptions = {
show: {
operation: ['createStore'],
resource: ['fileSearch'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const displayName = this.getNodeParameter('displayName', i, '') as string;
const response = await createFileSearchStore.call(this, displayName);
return [
{
json: response,
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,48 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { deleteFileSearchStore } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'File Search Store Name',
name: 'fileSearchStoreName',
type: 'string',
placeholder: 'e.g. fileSearchStores/abc123',
description: 'The full name of the File Search store to delete (format: fileSearchStores/...)',
default: '',
required: true,
},
{
displayName: 'Force Delete',
name: 'force',
type: 'boolean',
description:
'Whether to delete related Documents and objects. If false, deletion will fail if the store contains any Documents.',
default: false,
},
];
const displayOptions = {
show: {
operation: ['deleteStore'],
resource: ['fileSearch'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const fileSearchStoreName = this.getNodeParameter('fileSearchStoreName', i, '') as string;
const force = this.getNodeParameter('force', i, false) as boolean | undefined;
const response = await deleteFileSearchStore.call(this, fileSearchStoreName, force);
return [
{
json: response,
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,54 @@
import type { INodeProperties } from 'n8n-workflow';
import * as createStore from './createStore.operation';
import * as deleteStore from './deleteStore.operation';
import * as listStores from './listStores.operation';
import * as uploadToStore from './uploadToStore.operation';
export { createStore, deleteStore, listStores, uploadToStore };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Create File Search Store',
value: 'createStore',
action: 'Create a File Search store',
description: 'Create a new File Search store for RAG (Retrieval Augmented Generation)',
},
{
name: 'Delete File Search Store',
value: 'deleteStore',
action: 'Delete a File Search store',
description: 'Delete a File Search store',
},
{
name: 'List File Search Stores',
value: 'listStores',
action: 'List all File Search stores',
description: 'List all File Search stores owned by the user',
},
{
name: 'Upload to File Search Store',
value: 'uploadToStore',
action: 'Upload a file to a File Search store',
description:
'Upload a file to a File Search store for RAG (Retrieval Augmented Generation)',
},
],
default: 'createStore',
displayOptions: {
show: {
resource: ['fileSearch'],
},
},
},
...createStore.description,
...deleteStore.description,
...listStores.description,
...uploadToStore.description,
];
@@ -0,0 +1,50 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { listFileSearchStores } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'Page Size',
name: 'pageSize',
type: 'number',
description: 'Maximum number of File Search stores to return per page (max 20)',
default: 10,
typeOptions: {
minValue: 1,
maxValue: 20,
},
},
{
displayName: 'Page Token',
name: 'pageToken',
// eslint-disable-next-line -- pageToken is a pagination token, not a password
type: 'string',
description: 'Token from a previous page to retrieve the next page of results',
default: '',
},
];
const displayOptions = {
show: {
operation: ['listStores'],
resource: ['fileSearch'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const pageSize = this.getNodeParameter('pageSize', i) as number | undefined;
const pageToken = this.getNodeParameter('pageToken', i, '') as string | undefined;
const response = await listFileSearchStores.call(this, pageSize, pageToken);
return [
{
json: response,
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,107 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { uploadToFileSearchStore } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'File Search Store Name',
name: 'fileSearchStoreName',
type: 'string',
placeholder: 'e.g. fileSearchStores/abc123',
description:
'The full name of the File Search store to upload to (format: fileSearchStores/...)',
default: '',
required: true,
},
{
displayName: 'File Display Name',
name: 'displayName',
type: 'string',
placeholder: 'e.g. My Document',
description: 'A human-readable name for the file (will be visible in citations)',
default: '',
required: true,
},
{
displayName: 'Input Type',
name: 'inputType',
type: 'options',
default: 'url',
options: [
{
name: 'File URL',
value: 'url',
},
{
name: 'Binary File',
value: 'binary',
},
],
},
{
displayName: 'URL',
name: 'fileUrl',
type: 'string',
placeholder: 'e.g. https://example.com/file.pdf',
description: 'URL of the file to upload',
default: '',
displayOptions: {
show: {
inputType: ['url'],
},
},
},
{
displayName: 'Input Data Field Name',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
hint: 'The name of the input field containing the binary file data to be processed',
description: 'Name of the binary property which contains the file',
displayOptions: {
show: {
inputType: ['binary'],
},
},
},
];
const displayOptions = {
show: {
operation: ['uploadToStore'],
resource: ['fileSearch'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const fileSearchStoreName = this.getNodeParameter('fileSearchStoreName', i, '') as string;
const displayName = this.getNodeParameter('displayName', i, '') as string;
const inputType = this.getNodeParameter('inputType', i, 'url') as string;
let fileUrl: string | undefined;
if (inputType === 'url') {
fileUrl = this.getNodeParameter('fileUrl', i, '') as string;
}
const response = await uploadToFileSearchStore.call(
this,
i,
fileSearchStoreName,
displayName,
fileUrl,
'application/octet-stream',
);
return [
{
json: response ?? {},
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,102 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { baseAnalyze } from '../../helpers/baseAnalyze';
import { modelRLC } from '../descriptions';
const properties: INodeProperties[] = [
modelRLC('modelSearch'),
{
displayName: 'Text Input',
name: 'text',
type: 'string',
placeholder: "e.g. What's in this image?",
default: "What's in this image?",
typeOptions: {
rows: 2,
},
},
{
displayName: 'Input Type',
name: 'inputType',
type: 'options',
default: 'url',
options: [
{
name: 'Image URL(s)',
value: 'url',
},
{
name: 'Binary File(s)',
value: 'binary',
},
],
},
{
displayName: 'URL(s)',
name: 'imageUrls',
type: 'string',
placeholder: 'e.g. https://example.com/image.png',
description: 'URL(s) of the image(s) to analyze, multiple URLs can be added separated by comma',
default: '',
displayOptions: {
show: {
inputType: ['url'],
},
},
},
{
displayName: 'Input Data Field Name(s)',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
hint: 'The name of the input field containing the binary file data to be processed',
description:
'Name of the binary field(s) which contains the image(s), separate multiple field names with commas',
displayOptions: {
show: {
inputType: ['binary'],
},
},
},
{
displayName: 'Simplify Output',
name: 'simplify',
type: 'boolean',
default: true,
description: 'Whether to simplify the response or not',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Length of Description (Max Tokens)',
description: 'Fewer tokens will result in shorter, less detailed image description',
name: 'maxOutputTokens',
type: 'number',
default: 300,
typeOptions: {
minValue: 1,
},
},
],
},
];
const displayOptions = {
show: {
operation: ['analyze'],
resource: ['image'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
return await baseAnalyze.call(this, i, 'imageUrls', 'image/png');
}
@@ -0,0 +1,248 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IBinaryData, ICredentialDataDecryptedObject, IExecuteFunctions } from 'n8n-workflow';
import { execute } from './edit.operation';
const getMockedExecuteFunctions = ({
prompt = 'add bananas',
outputProperty = 'edited',
images = { values: [{ binaryPropertyName: 'data' }] },
outputBuffer = Buffer.from('edited image'),
mimeType = 'image/png',
invalidApiResponse = false,
}: {
prompt?: string;
outputProperty?: string;
images?: { values: Array<{ binaryPropertyName: string }> };
outputBuffer?: Buffer<ArrayBuffer>;
mimeType?: string;
invalidApiResponse?: boolean;
} = {}): IExecuteFunctions => {
const executeFunctions = mockDeep<IExecuteFunctions>();
executeFunctions.getCredentials
.calledWith('googlePalmApi')
.mockResolvedValue(mock<ICredentialDataDecryptedObject>());
executeFunctions.getNodeParameter.calledWith('prompt', 0).mockReturnValue(prompt);
executeFunctions.getNodeParameter
.calledWith('options.binaryPropertyOutput', 0)
.mockReturnValue(outputProperty);
executeFunctions.getNodeParameter.calledWith('images', 0).mockReturnValue(images);
executeFunctions.helpers.assertBinaryData.mockReturnValue(mock<IBinaryData>({ mimeType }));
executeFunctions.helpers.getBinaryDataBuffer.mockImplementation(async (_index, propertyName) =>
Buffer.from(`${propertyName} data`),
);
executeFunctions.helpers.prepareBinaryData.mockImplementation(async (buffer, filename, mime) => ({
data: (buffer as Buffer).toString('base64'),
fileName: filename ?? 'image.png',
fileSize: (buffer as Buffer).length.toString(),
mimeType: mime ?? mimeType,
}));
executeFunctions.helpers.httpRequest
.calledWith(
expect.objectContaining({
url: expect.stringContaining('/upload/v1beta/files'),
}),
)
.mockResolvedValue({
headers: { 'x-goog-upload-url': 'https://mock-upload-url.com' },
});
executeFunctions.helpers.httpRequestWithAuthentication
.calledWith(
'googlePalmApi',
expect.objectContaining({
headers: expect.objectContaining({ 'X-Goog-Upload-Protocol': expect.any(String) }),
}),
)
.mockResolvedValue({
headers: { 'x-goog-upload-url': 'https://mock-upload-url.com' },
});
executeFunctions.helpers.httpRequestWithAuthentication
.calledWith(
'googlePalmApi',
expect.objectContaining({
url: expect.stringContaining('generateContent'),
}),
)
.mockResolvedValue(
invalidApiResponse
? { invalid: 'response' }
: {
candidates: [
{
content: {
parts: [{ inlineData: { data: outputBuffer.toString('base64'), mimeType } }],
},
},
],
},
);
executeFunctions.helpers.httpRequest
.calledWith(
expect.objectContaining({
method: 'POST',
url: expect.stringContaining('mock-upload-url'),
}),
)
.mockResolvedValue({
file: { name: 'files/test', uri: 'mockFileUri', mimeType, state: 'ACTIVE' },
});
return executeFunctions;
};
describe('Gemini Node image edit', () => {
it('should edit an image successfully', async () => {
const executeFunctions = getMockedExecuteFunctions();
const result = await execute.call(executeFunctions, 0);
expect(result).toEqual([
{
binary: {
edited: {
data: 'ZWRpdGVkIGltYWdl',
fileName: 'image.png',
fileSize: '12',
mimeType: 'image/png',
},
},
json: { fileName: 'image.png', fileSize: '12', mimeType: 'image/png', data: undefined },
pairedItem: { item: 0 },
},
]);
});
it('should handle custom output property name', async () => {
const executeFunctions = getMockedExecuteFunctions({
prompt: 'edit this image',
outputProperty: 'custom_output',
});
const result = await execute.call(executeFunctions, 0);
expect(result[0].binary).toHaveProperty('custom_output');
expect(result[0].binary).not.toHaveProperty('edited');
});
it('should handle multiple images', async () => {
const multiImageConfig = {
values: [{ binaryPropertyName: 'image1' }, { binaryPropertyName: 'image2' }],
};
const executeFunctions = getMockedExecuteFunctions({
prompt: 'combine images',
outputProperty: 'combined',
images: multiImageConfig,
outputBuffer: Buffer.from('combined image'),
});
const result = await execute.call(executeFunctions, 0);
expect(result[0].binary).toHaveProperty('combined');
});
it('should throw error for invalid images parameter', async () => {
const executeFunctions = getMockedExecuteFunctions(
{ prompt: 'test prompt', outputProperty: 'edited', images: { values: 'invalid' as never } }, // Invalid format
);
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
'Invalid images parameter format',
);
});
it('should throw error when no image data returned from API', async () => {
const executeFunctions = getMockedExecuteFunctions(
{
prompt: 'test prompt',
outputProperty: 'edited',
images: { values: [{ binaryPropertyName: 'data' }] },
outputBuffer: Buffer.from(''),
}, // Empty buffer to trigger error
);
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
'No image data returned from Gemini API',
);
});
it('should throw error for invalid API response format', async () => {
const executeFunctions = getMockedExecuteFunctions({
prompt: 'test prompt',
outputProperty: 'edited',
images: { values: [{ binaryPropertyName: 'data' }] },
invalidApiResponse: true,
});
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
'Invalid response format from Gemini API',
);
});
it('should handle empty prompt', async () => {
const executeFunctions = getMockedExecuteFunctions({ prompt: '' });
const result = await execute.call(executeFunctions, 0);
expect(result).toEqual([
{
binary: {
edited: {
data: 'ZWRpdGVkIGltYWdl',
fileName: 'image.png',
fileSize: '12',
mimeType: 'image/png',
},
},
json: { fileName: 'image.png', fileSize: '12', mimeType: 'image/png', data: undefined },
pairedItem: { item: 0 },
},
]);
});
it('should handle different MIME types', async () => {
const executeFunctions = getMockedExecuteFunctions({
prompt: 'enhance image',
outputProperty: 'enhanced',
images: { values: [{ binaryPropertyName: 'data' }] },
outputBuffer: Buffer.from('enhanced jpeg'),
mimeType: 'image/jpeg',
});
const result = await execute.call(executeFunctions, 0);
expect(result[0]?.binary?.enhanced?.mimeType).toBe('image/jpeg');
expect(result[0]?.json?.mimeType).toBe('image/jpeg');
});
it('should handle empty images array when no valid binary property names', async () => {
const executeFunctions = getMockedExecuteFunctions({
prompt: 'test prompt',
outputProperty: 'edited',
images: { values: [{ binaryPropertyName: '' }] },
outputBuffer: Buffer.from('no image response'),
});
const result = await execute.call(executeFunctions, 0);
expect(result).toEqual([
{
binary: {
edited: {
data: 'bm8gaW1hZ2UgcmVzcG9uc2U=',
fileName: 'image.png',
fileSize: '17',
mimeType: 'image/png',
},
},
json: { fileName: 'image.png', fileSize: '17', mimeType: 'image/png', data: undefined },
pairedItem: { item: 0 },
},
]);
});
});
@@ -0,0 +1,232 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import type { GenerateContentResponse } from '../../helpers/interfaces';
import { uploadFile } from '../../helpers/utils';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
interface ImagesParameter {
values?: Array<{ binaryPropertyName?: string }>;
}
function isImagesParameter(param: unknown): param is ImagesParameter {
if (typeof param !== 'object' || param === null) {
return false;
}
const paramObj = param as Record<string, unknown>;
if (!('values' in paramObj)) {
return true; // values is optional
}
if (!Array.isArray(paramObj.values)) {
return false;
}
return paramObj.values.every((item: unknown) => {
if (typeof item !== 'object' || item === null) {
return false;
}
const itemObj = item as Record<string, unknown>;
if (!('binaryPropertyName' in itemObj)) {
return true; // binaryPropertyName is optional
}
return (
typeof itemObj.binaryPropertyName === 'string' || itemObj.binaryPropertyName === undefined
);
});
}
function isGenerateContentResponse(response: unknown): response is GenerateContentResponse {
if (typeof response !== 'object' || response === null) {
return false;
}
const responseObj = response as Record<string, unknown>;
if (!('candidates' in responseObj) || !Array.isArray(responseObj.candidates)) {
return false;
}
return responseObj.candidates.every((candidate: unknown) => {
if (typeof candidate !== 'object' || candidate === null) {
return false;
}
const candidateObj = candidate as Record<string, unknown>;
if (
!('content' in candidateObj) ||
typeof candidateObj.content !== 'object' ||
candidateObj.content === null
) {
return false;
}
const contentObj = candidateObj.content as Record<string, unknown>;
return 'parts' in contentObj && Array.isArray(contentObj.parts);
});
}
const properties: INodeProperties[] = [
modelRLC('imageEditModelSearch'),
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
placeholder: 'e.g. combine the first image with the second image',
description: 'Instruction describing how to edit the image',
default: '',
typeOptions: {
rows: 2,
},
},
{
displayName: 'Images',
name: 'images',
type: 'fixedCollection',
placeholder: 'Add Image',
typeOptions: {
multipleValues: true,
multipleValueButtonText: 'Add Image',
},
default: { values: [{ binaryPropertyName: 'data' }] },
description: 'Add one or more binary fields to include images with your prompt',
options: [
{
displayName: 'Image',
name: 'values',
values: [
{
displayName: 'Binary Field Name',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
description: 'The name of the binary field containing the image data',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Put Output in Field',
name: 'binaryPropertyOutput',
type: 'string',
default: 'edited',
hint: 'The name of the output field to put the binary file data in',
},
],
},
];
const displayOptions = {
show: {
operation: ['edit'],
resource: ['image'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const prompt = this.getNodeParameter('prompt', i, '');
let model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
if (!model) {
model = 'models/gemini-2.5-flash-image-preview';
}
const binaryPropertyOutput = this.getNodeParameter('options.binaryPropertyOutput', i, 'edited');
const outputKey = typeof binaryPropertyOutput === 'string' ? binaryPropertyOutput : 'data';
// Collect image binary field names from collection
const imagesParam = this.getNodeParameter('images', i, {
values: [{ binaryPropertyName: 'data' }],
});
if (!isImagesParameter(imagesParam)) {
throw new Error('Invalid images parameter format');
}
const imagesUi = imagesParam.values ?? [];
const imageFieldNames = imagesUi
.map((v) => v.binaryPropertyName)
.filter((n): n is string => Boolean(n));
// Upload all images and gather fileData parts
const fileParts = [] as Array<{ fileData: { fileUri: string; mimeType: string } }>;
for (const fieldName of imageFieldNames) {
const bin = this.helpers.assertBinaryData(i, fieldName);
const buf = await this.helpers.getBinaryDataBuffer(i, fieldName);
const uploaded = await uploadFile.call(this, buf, bin.mimeType);
fileParts.push({ fileData: { fileUri: uploaded.fileUri, mimeType: uploaded.mimeType } });
}
const generationConfig = {
responseModalities: ['IMAGE'],
};
const body = {
contents: [
{
role: 'user',
parts: [...fileParts, { text: prompt }],
},
],
generationConfig,
};
const response: unknown = await apiRequest.call(
this,
'POST',
`/v1beta/${model}:generateContent`,
{
body,
},
);
if (!isGenerateContentResponse(response)) {
throw new Error('Invalid response format from Gemini API');
}
const promises = response.candidates.map(async (candidate) => {
const imagePart = candidate.content.parts.find((part) => 'inlineData' in part);
// Check if imagePart exists and has inlineData with actual data
if (!imagePart?.inlineData?.data) {
throw new Error('No image data returned from Gemini API');
}
const bufferOut = Buffer.from(imagePart.inlineData.data, 'base64');
const binaryOut = await this.helpers.prepareBinaryData(
bufferOut,
'image.png',
imagePart.inlineData.mimeType,
);
return {
binary: {
[outputKey]: binaryOut,
},
json: {
...binaryOut,
data: undefined,
},
pairedItem: { item: i },
};
});
return await Promise.all(promises);
}
@@ -0,0 +1,157 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
import {
type GenerateContentRequest,
type GenerateContentResponse,
type ImagenResponse,
Modality,
} from '../../helpers/interfaces';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
const properties: INodeProperties[] = [
modelRLC('imageGenerationModelSearch'),
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
placeholder: 'e.g. A cute cat eating a dinosaur',
description: 'A text description of the desired image(s)',
default: '',
typeOptions: {
rows: 2,
},
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Number of Images',
name: 'sampleCount',
default: 1,
description:
'Number of images to generate. Not supported by Gemini models, supported by Imagen models.',
type: 'number',
typeOptions: {
minValue: 1,
},
},
{
displayName: 'Put Output in Field',
name: 'binaryPropertyOutput',
type: 'string',
default: 'data',
hint: 'The name of the output field to put the binary file data in',
},
],
},
];
const displayOptions = {
show: {
operation: ['generate'],
resource: ['image'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
const prompt = this.getNodeParameter('prompt', i, '') as string;
const binaryPropertyOutput = this.getNodeParameter(
'options.binaryPropertyOutput',
i,
'data',
) as string;
if (model.includes('gemini')) {
const generationConfig = {
responseModalities: [Modality.IMAGE, Modality.TEXT],
};
const body: GenerateContentRequest = {
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
generationConfig,
};
const response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, {
body,
})) as GenerateContentResponse;
const promises = response.candidates.map(async (candidate) => {
const imagePart = candidate.content.parts.find((part) => 'inlineData' in part);
const buffer = Buffer.from(imagePart?.inlineData.data ?? '', 'base64');
const binaryData = await this.helpers.prepareBinaryData(
buffer,
'image.png',
imagePart?.inlineData.mimeType,
);
return {
binary: {
[binaryPropertyOutput]: binaryData,
},
json: {
...binaryData,
data: undefined,
},
pairedItem: { item: i },
};
});
return await Promise.all(promises);
} else if (model.includes('imagen')) {
// Imagen models use a different endpoint and request/response structure
const sampleCount = this.getNodeParameter('options.sampleCount', i, 1) as number;
const body = {
instances: [
{
prompt,
},
],
parameters: {
sampleCount,
},
};
const response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:predict`, {
body,
})) as ImagenResponse;
const promises = response.predictions.map(async (prediction) => {
const buffer = Buffer.from(prediction.bytesBase64Encoded ?? '', 'base64');
const binaryData = await this.helpers.prepareBinaryData(
buffer,
'image.png',
prediction.mimeType,
);
return {
binary: {
[binaryPropertyOutput]: binaryData,
},
json: {
...binaryData,
data: undefined,
},
pairedItem: { item: i },
};
});
return await Promise.all(promises);
}
throw new NodeOperationError(
this.getNode(),
`Model ${model} is not supported for image generation`,
{
description: 'Please check the model ID and try again.',
},
);
}
@@ -0,0 +1,45 @@
import type { INodeProperties } from 'n8n-workflow';
import * as analyze from './analyze.operation';
import * as edit from './edit.operation';
import * as generate from './generate.operation';
export { analyze, generate, edit };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Analyze Image',
value: 'analyze',
action: 'Analyze an image',
description: 'Take in images and answer questions about them',
},
{
name: 'Generate an Image',
value: 'generate',
action: 'Generate an image',
description: 'Creates an image from a text prompt',
},
{
name: 'Edit Image',
value: 'edit',
action: 'Edit an image',
description: 'Upload one or more images and apply edits based on a prompt',
},
],
default: 'generate',
displayOptions: {
show: {
resource: ['image'],
},
},
},
...analyze.description,
...edit.description,
...generate.description,
];
@@ -0,0 +1,13 @@
import type { AllEntities } from 'n8n-workflow';
type NodeMap = {
text: 'message';
image: 'analyze' | 'generate';
video: 'analyze' | 'generate' | 'download';
audio: 'transcribe' | 'analyze';
document: 'analyze';
file: 'upload';
fileSearch: 'createStore' | 'deleteStore' | 'listStores' | 'uploadToStore';
};
export type GoogleGeminiType = AllEntities<NodeMap>;
@@ -0,0 +1,136 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import * as audio from './audio';
import * as document from './document';
import * as file from './file';
import * as fileSearch from './fileSearch';
import * as image from './image';
import { router } from './router';
import * as text from './text';
import * as video from './video';
describe('Google Gemini router', () => {
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
const mockAudio = jest.spyOn(audio.analyze, 'execute');
const mockDocument = jest.spyOn(document.analyze, 'execute');
const mockFile = jest.spyOn(file.upload, 'execute');
const mockFileSearchCreateStore = jest.spyOn(fileSearch.createStore, 'execute');
const mockFileSearchDeleteStore = jest.spyOn(fileSearch.deleteStore, 'execute');
const mockFileSearchListStores = jest.spyOn(fileSearch.listStores, 'execute');
const mockFileSearchUploadToStore = jest.spyOn(fileSearch.uploadToStore, 'execute');
const mockImage = jest.spyOn(image.analyze, 'execute');
const mockText = jest.spyOn(text.message, 'execute');
const mockVideo = jest.spyOn(video.analyze, 'execute');
const operationMocks = [
[mockAudio, 'audio', 'analyze'],
[mockDocument, 'document', 'analyze'],
[mockFile, 'file', 'upload'],
[mockFileSearchCreateStore, 'fileSearch', 'createStore'],
[mockFileSearchDeleteStore, 'fileSearch', 'deleteStore'],
[mockFileSearchListStores, 'fileSearch', 'listStores'],
[mockFileSearchUploadToStore, 'fileSearch', 'uploadToStore'],
[mockImage, 'image', 'analyze'],
[mockText, 'text', 'message'],
[mockVideo, 'video', 'analyze'],
];
beforeEach(() => {
jest.clearAllMocks();
});
it.each(operationMocks)('should call the correct method', async (mock, resource, operation) => {
mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) =>
parameter === 'resource' ? resource : operation,
);
mockExecuteFunctions.getInputData.mockReturnValue([
{
json: {},
},
]);
(mock as jest.Mock).mockResolvedValue([
{
json: {
foo: 'bar',
},
},
]);
const result = await router.call(mockExecuteFunctions);
expect(mock).toHaveBeenCalledWith(0);
expect(result).toEqual([[{ json: { foo: 'bar' } }]]);
});
it('should return an error if the operation is not supported', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) =>
parameter === 'resource' ? 'foo' : 'bar',
);
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
await expect(router.call(mockExecuteFunctions)).rejects.toThrow(
'The operation "bar" is not supported!',
);
});
it('should loop over all items', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) =>
parameter === 'resource' ? 'audio' : 'analyze',
);
mockExecuteFunctions.getInputData.mockReturnValue([
{
json: {
text: 'item 1',
},
},
{
json: {
text: 'item 2',
},
},
{
json: {
text: 'item 3',
},
},
]);
mockAudio.mockResolvedValueOnce([{ json: { response: 'foo' } }]);
mockAudio.mockResolvedValueOnce([{ json: { response: 'bar' } }]);
mockAudio.mockResolvedValueOnce([{ json: { response: 'baz' } }]);
const result = await router.call(mockExecuteFunctions);
expect(result).toEqual([
[{ json: { response: 'foo' } }, { json: { response: 'bar' } }, { json: { response: 'baz' } }],
]);
});
it('should continue on fail', async () => {
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) =>
parameter === 'resource' ? 'audio' : 'analyze',
);
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }, { json: {} }]);
mockAudio.mockRejectedValue(new Error('Some error'));
const result = await router.call(mockExecuteFunctions);
expect(result).toEqual([
[
{ json: { error: 'Some error' }, pairedItem: { item: 0 } },
{ json: { error: 'Some error' }, pairedItem: { item: 1 } },
],
]);
});
it('should throw an error if continueOnFail is false', async () => {
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) =>
parameter === 'resource' ? 'audio' : 'analyze',
);
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockAudio.mockRejectedValue(new Error('Some error'));
await expect(router.call(mockExecuteFunctions)).rejects.toThrow('Some error');
});
});
@@ -0,0 +1,72 @@
import { NodeOperationError, type IExecuteFunctions, type INodeExecutionData } from 'n8n-workflow';
import * as audio from './audio';
import * as document from './document';
import * as file from './file';
import * as fileSearch from './fileSearch';
import * as image from './image';
import type { GoogleGeminiType } from './node.type';
import * as text from './text';
import * as video from './video';
export async function router(this: IExecuteFunctions) {
const returnData: INodeExecutionData[] = [];
const items = this.getInputData();
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const googleGeminiTypeData = {
resource,
operation,
} as GoogleGeminiType;
let execute;
switch (googleGeminiTypeData.resource) {
case 'audio':
execute = audio[googleGeminiTypeData.operation].execute;
break;
case 'document':
execute = document[googleGeminiTypeData.operation].execute;
break;
case 'file':
execute = file[googleGeminiTypeData.operation].execute;
break;
case 'fileSearch':
execute = fileSearch[googleGeminiTypeData.operation].execute;
break;
case 'image':
execute = image[googleGeminiTypeData.operation].execute;
break;
case 'text':
execute = text[googleGeminiTypeData.operation].execute;
break;
case 'video':
execute = video[googleGeminiTypeData.operation].execute;
break;
default:
throw new NodeOperationError(
this.getNode(),
`The operation "${operation}" is not supported!`,
);
}
for (let i = 0; i < items.length; i++) {
try {
const responseData = await execute.call(this, i);
returnData.push(...responseData);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
continue;
}
throw new NodeOperationError(this.getNode(), error, {
itemIndex: i,
description: error.description,
});
}
}
return [returnData];
}
@@ -0,0 +1,29 @@
import type { INodeProperties } from 'n8n-workflow';
import * as message from './message.operation';
export { message };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Message a Model',
value: 'message',
action: 'Message a model',
description: 'Create a completion with Google Gemini model',
},
],
default: 'message',
displayOptions: {
show: {
resource: ['text'],
},
},
},
...message.description,
];
@@ -0,0 +1,584 @@
import { getConnectedTools } from '@utils/helpers';
import {
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
jsonParse,
updateDisplayOptions,
validateNodeParameters,
} from 'n8n-workflow';
import zodToJsonSchema from 'zod-to-json-schema';
import type {
GenerateContentRequest,
GenerateContentResponse,
Content,
Tool,
GenerateContentGenerationConfig,
BuiltInTools,
} from '../../helpers/interfaces';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
const properties: INodeProperties[] = [
modelRLC('modelSearch'),
{
displayName: 'Messages',
name: 'messages',
type: 'fixedCollection',
typeOptions: {
sortable: true,
multipleValues: true,
},
placeholder: 'Add Message',
default: { values: [{ content: '' }] },
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Prompt',
name: 'content',
type: 'string',
description: 'The content of the message to be send',
default: '',
placeholder: 'e.g. Hello, how can you help me?',
typeOptions: {
rows: 2,
},
},
{
displayName: 'Role',
name: 'role',
type: 'options',
description:
"Role in shaping the model's response, it tells the model how it should behave and interact with the user",
options: [
{
name: 'User',
value: 'user',
description: 'Send a message as a user and get a response from the model',
},
{
name: 'Model',
value: 'model',
description: 'Tell the model to adopt a specific tone or personality',
},
],
default: 'user',
},
],
},
],
},
{
displayName: 'Simplify Output',
name: 'simplify',
type: 'boolean',
default: true,
description: 'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Output Content as JSON',
name: 'jsonOutput',
type: 'boolean',
description: 'Whether to attempt to return the response in JSON format',
default: false,
},
{
displayName: 'Built-in Tools',
name: 'builtInTools',
placeholder: 'Add Built-in Tool',
type: 'collection',
default: {},
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
options: [
{
displayName: 'Google Search',
name: 'googleSearch',
type: 'boolean',
default: true,
description:
'Whether to allow the model to search the web using Google Search to get real-time information',
},
{
displayName: 'Google Maps',
name: 'googleMaps',
type: 'collection',
default: { latitude: '', longitude: '' },
options: [
{
displayName: 'Latitude',
name: 'latitude',
type: 'number',
default: '',
description: 'The latitude coordinate for location-based queries',
typeOptions: {
numberPrecision: 6,
},
},
{
displayName: 'Longitude',
name: 'longitude',
type: 'number',
default: '',
description: 'The longitude coordinate for location-based queries',
typeOptions: {
numberPrecision: 6,
},
},
],
},
{
displayName: 'URL Context',
name: 'urlContext',
type: 'boolean',
default: true,
description: 'Whether to allow the model to read and analyze content from specific URLs',
},
{
displayName: 'File Search',
name: 'fileSearch',
type: 'collection',
default: { fileSearchStoreNames: '[]' },
options: [
{
displayName: 'File Search Store Names',
name: 'fileSearchStoreNames',
description:
'The file search store names to use for the file search. File search stores are managed via Google AI Studio.',
type: 'json',
default: '[]',
required: true,
},
{
displayName: 'Metadata Filter',
name: 'metadataFilter',
type: 'string',
default: '',
description:
'Use metadata filter to search within a subset of documents. Example: author="Robert Graves".',
placeholder: 'e.g. author="John Doe"',
},
],
},
{
displayName: 'Code Execution',
name: 'codeExecution',
type: 'boolean',
default: true,
description:
'Whether to allow the model to execute code it generates to produce a response. Supported only by certain models.',
},
],
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Include Merged Response',
name: 'includeMergedResponse',
type: 'boolean',
default: false,
description:
'Whether to include a single output string merging all text parts of the response',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
displayName: 'System Message',
name: 'systemMessage',
type: 'string',
default: '',
placeholder: 'e.g. You are a helpful assistant',
},
{
displayName: 'Code Execution',
name: 'codeExecution',
type: 'boolean',
default: false,
description:
'Whether to allow the model to execute code it generates to produce a response. Supported only by certain models.',
displayOptions: {
show: {
'@version': [{ _cnd: { eq: 1 } }],
},
},
},
{
displayName: 'Frequency Penalty',
name: 'frequencyPenalty',
default: 0,
description:
"Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim",
type: 'number',
typeOptions: {
minValue: -2,
maxValue: 2,
numberPrecision: 1,
},
},
{
displayName: 'Maximum Number of Tokens',
name: 'maxOutputTokens',
default: 16,
description: 'The maximum number of tokens to generate in the completion',
type: 'number',
typeOptions: {
minValue: 1,
numberPrecision: 0,
},
},
{
displayName: 'Number of Completions',
name: 'candidateCount',
default: 1,
description: 'How many completions to generate for each prompt',
type: 'number',
typeOptions: {
minValue: 1,
maxValue: 8, // Google Gemini supports up to 8 candidates
numberPrecision: 0,
},
},
{
displayName: 'Presence Penalty',
name: 'presencePenalty',
default: 0,
description:
"Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics",
type: 'number',
typeOptions: {
minValue: -2,
maxValue: 2,
numberPrecision: 1,
},
},
{
displayName: 'Output Randomness (Temperature)',
name: 'temperature',
default: 1,
description:
'Controls the randomness of the output. Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive',
type: 'number',
typeOptions: {
minValue: 0,
maxValue: 2,
numberPrecision: 1,
},
},
{
displayName: 'Output Randomness (Top P)',
name: 'topP',
default: 1,
description: 'The maximum cumulative probability of tokens to consider when sampling',
type: 'number',
typeOptions: {
minValue: 0,
maxValue: 1,
numberPrecision: 1,
},
},
{
displayName: 'Output Randomness (Top K)',
name: 'topK',
default: 1,
description: 'The maximum number of tokens to consider when sampling',
type: 'number',
typeOptions: {
minValue: 1,
numberPrecision: 0,
},
},
{
displayName: 'Thinking Budget',
name: 'thinkingBudget',
type: 'number',
default: -1,
description:
'Controls reasoning tokens for thinking models. Set to 0 to disable automatic thinking. Set to -1 for dynamic thinking (default).',
typeOptions: {
minValue: -1,
numberPrecision: 0,
},
},
{
displayName: 'Max Tool Calls Iterations',
name: 'maxToolsIterations',
type: 'number',
default: 15,
description:
'The maximum number of tool iteration cycles the LLM will run before stopping. A single iteration can contain multiple tool calls. Set to 0 for no limit',
typeOptions: {
minValue: 0,
numberPrecision: 0,
},
},
],
},
];
const displayOptions = {
show: {
operation: ['message'],
resource: ['text'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
function getToolCalls(response: GenerateContentResponse) {
return response.candidates.flatMap((c) => c.content.parts).filter((p) => 'functionCall' in p);
}
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
const messages = this.getNodeParameter('messages.values', i, []) as Array<{
content: string;
role: string;
}>;
const simplify = this.getNodeParameter('simplify', i, true) as boolean;
const jsonOutput = this.getNodeParameter('jsonOutput', i, false) as boolean;
const options = this.getNodeParameter('options', i, {});
const builtInTools = this.getNodeParameter('builtInTools', i, {}) as BuiltInTools;
validateNodeParameters(
options,
{
includeMergedResponse: { type: 'boolean', required: false },
systemMessage: { type: 'string', required: false },
codeExecution: { type: 'boolean', required: false },
frequencyPenalty: { type: 'number', required: false },
maxOutputTokens: { type: 'number', required: false },
candidateCount: { type: 'number', required: false },
presencePenalty: { type: 'number', required: false },
temperature: { type: 'number', required: false },
topP: { type: 'number', required: false },
topK: { type: 'number', required: false },
thinkingBudget: { type: 'number', required: false },
maxToolsIterations: { type: 'number', required: false },
},
this.getNode(),
);
const generationConfig: GenerateContentGenerationConfig = {
frequencyPenalty: options.frequencyPenalty,
maxOutputTokens: options.maxOutputTokens,
candidateCount: options.candidateCount,
presencePenalty: options.presencePenalty,
temperature: options.temperature,
topP: options.topP,
topK: options.topK,
responseMimeType: jsonOutput ? 'application/json' : undefined,
};
// Add thinkingConfig if thinkingBudget is specified
if (options.thinkingBudget !== undefined) {
generationConfig.thinkingConfig = {
thinkingBudget: options.thinkingBudget,
};
}
const nodeInputs = this.getNodeInputs();
const availableTools = nodeInputs.some((i) => i.type === 'ai_tool')
? await getConnectedTools(this, true)
: [];
const tools: Tool[] = [
{
functionDeclarations: availableTools.map((t) => ({
name: t.name,
description: t.description,
parameters: {
...zodToJsonSchema(t.schema, { target: 'openApi3' }),
// Google Gemini API throws an error if `additionalProperties` field is present
additionalProperties: undefined,
},
})),
},
];
if (!tools[0].functionDeclarations?.length) {
tools.pop();
}
if (this.getNode().typeVersion === 1) {
if (options.codeExecution) {
tools.push({
codeExecution: {},
});
}
}
// Add built-in tools and build toolConfig
let toolConfig: GenerateContentRequest['toolConfig'];
if (this.getNode().typeVersion >= 1.1) {
if (builtInTools) {
if (builtInTools.googleSearch) {
tools.push({
googleSearch: {},
});
}
const googleMapsOptions = builtInTools.googleMaps;
if (googleMapsOptions) {
tools.push({
googleMaps: {},
});
// Build toolConfig with retrievalConfig if latitude/longitude are provided
const latitude = googleMapsOptions.latitude;
const longitude = googleMapsOptions.longitude;
if (
latitude !== undefined &&
latitude !== '' &&
longitude !== undefined &&
longitude !== ''
) {
toolConfig = {
retrievalConfig: {
latLng: {
latitude: Number(latitude),
longitude: Number(longitude),
},
},
};
}
}
if (builtInTools.urlContext) {
tools.push({
urlContext: {},
});
}
const fileSearchOptions = builtInTools.fileSearch;
if (fileSearchOptions) {
const fileSearchStoreNamesRaw = fileSearchOptions.fileSearchStoreNames;
const metadataFilter = fileSearchOptions.metadataFilter;
let fileSearchStoreNames: string[] | undefined;
if (fileSearchStoreNamesRaw) {
const parsed = jsonParse(fileSearchStoreNamesRaw, {
errorMessage: 'Failed to parse file search store names',
});
if (Array.isArray(parsed)) {
fileSearchStoreNames = parsed;
}
}
tools.push({
fileSearch: {
...(fileSearchStoreNames && { fileSearchStoreNames }),
...(metadataFilter && { metadataFilter }),
},
});
}
if (builtInTools.codeExecution) {
tools.push({
codeExecution: {},
});
}
}
}
const contents: Content[] = messages.map((m) => ({
parts: [{ text: m.content }],
role: m.role,
}));
const body: GenerateContentRequest = {
tools,
contents,
generationConfig,
systemInstruction: options.systemMessage
? { parts: [{ text: options.systemMessage }] }
: undefined,
...(toolConfig && { toolConfig }),
};
let response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, {
body,
})) as GenerateContentResponse;
const maxToolsIterations = this.getNodeParameter('options.maxToolsIterations', i, 15) as number;
const abortSignal = this.getExecutionCancelSignal();
let currentIteration = 1;
let toolCalls = getToolCalls(response);
while (toolCalls.length) {
if (
(maxToolsIterations > 0 && currentIteration >= maxToolsIterations) ||
abortSignal?.aborted
) {
break;
}
contents.push(...response.candidates.map((c) => c.content));
for (const { functionCall } of toolCalls) {
let toolResponse;
for (const availableTool of availableTools) {
if (availableTool.name === functionCall.name) {
toolResponse = (await availableTool.invoke(functionCall.args)) as IDataObject;
}
}
contents.push({
parts: [
{
functionResponse: {
id: functionCall.id,
name: functionCall.name,
response: {
result: toolResponse,
},
},
},
],
role: 'tool',
});
}
response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, {
body,
})) as GenerateContentResponse;
toolCalls = getToolCalls(response);
currentIteration++;
}
const candidates = options.includeMergedResponse
? response.candidates.map((candidate) => ({
...candidate,
mergedResponse: candidate.content.parts
.filter((part) => 'text' in part)
.map((part) => (part as { text: string }).text)
.join(''),
}))
: response.candidates;
if (simplify) {
return candidates.map((candidate) => ({
json: candidate,
pairedItem: { item: i },
}));
}
return [
{
json: {
...response,
candidates,
},
pairedItem: { item: i },
},
];
}
@@ -0,0 +1,103 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
import * as audio from './audio';
import * as document from './document';
import * as file from './file';
import * as fileSearch from './fileSearch';
import * as image from './image';
import * as text from './text';
import * as video from './video';
export const versionDescription: INodeTypeDescription = {
displayName: 'Google Gemini',
name: 'googleGemini',
icon: 'file:gemini.svg',
group: ['transform'],
version: [1, 1.1],
defaultVersion: 1.1,
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
description: 'Interact with Google Gemini AI models',
defaults: {
name: 'Google Gemini',
},
usableAsTool: true,
codex: {
alias: ['LangChain', 'video', 'document', 'audio', 'transcribe', 'assistant'],
categories: ['AI'],
subcategories: {
AI: ['Agents', 'Miscellaneous', 'Root Nodes'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.googlegemini/',
},
],
},
},
inputs: `={{
(() => {
const resource = $parameter.resource;
const operation = $parameter.operation;
if (resource === 'text' && operation === 'message') {
return [{ type: 'main' }, { type: 'ai_tool', displayName: 'Tools' }];
}
return ['main'];
})()
}}`,
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googlePalmApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Audio',
value: 'audio',
},
{
name: 'Document',
value: 'document',
},
{
name: 'File Search',
value: 'fileSearch',
},
{
name: 'Image',
value: 'image',
},
{
name: 'Media File',
value: 'file',
},
{
name: 'Text',
value: 'text',
},
{
name: 'Video',
value: 'video',
},
],
default: 'text',
},
...audio.description,
...document.description,
...file.description,
...fileSearch.description,
...image.description,
...text.description,
...video.description,
],
};
@@ -0,0 +1,102 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { baseAnalyze } from '../../helpers/baseAnalyze';
import { modelRLC } from '../descriptions';
const properties: INodeProperties[] = [
modelRLC('modelSearch'),
{
displayName: 'Text Input',
name: 'text',
type: 'string',
placeholder: "e.g. What's in this video?",
default: "What's in this video?",
typeOptions: {
rows: 2,
},
},
{
displayName: 'Input Type',
name: 'inputType',
type: 'options',
default: 'url',
options: [
{
name: 'Video URL(s)',
value: 'url',
},
{
name: 'Binary File(s)',
value: 'binary',
},
],
},
{
displayName: 'URL(s)',
name: 'videoUrls',
type: 'string',
placeholder: 'e.g. https://example.com/video.mp4',
description: 'URL(s) of the video(s) to analyze, multiple URLs can be added separated by comma',
default: '',
displayOptions: {
show: {
inputType: ['url'],
},
},
},
{
displayName: 'Input Data Field Name(s)',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
hint: 'The name of the input field containing the binary file data to be processed',
description:
'Name of the binary field(s) which contains the video(s), seperate multiple field names with commas',
displayOptions: {
show: {
inputType: ['binary'],
},
},
},
{
displayName: 'Simplify Output',
name: 'simplify',
type: 'boolean',
default: true,
description: 'Whether to simplify the response or not',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Length of Description (Max Tokens)',
description: 'Fewer tokens will result in shorter, less detailed video description',
name: 'maxOutputTokens',
type: 'number',
default: 300,
typeOptions: {
minValue: 1,
},
},
],
},
];
const displayOptions = {
show: {
operation: ['analyze'],
resource: ['video'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
return await baseAnalyze.call(this, i, 'videoUrls', 'video/mp4');
}
@@ -0,0 +1,64 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { downloadFile } from '../../helpers/utils';
const properties: INodeProperties[] = [
{
displayName: 'URL',
name: 'url',
type: 'string',
placeholder: 'e.g. https://generativelanguage.googleapis.com/v1beta/files/abcdefg:download',
description: 'The URL from Google Gemini API to download the video from',
default: '',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Put Output in Field',
name: 'binaryPropertyOutput',
type: 'string',
default: 'data',
hint: 'The name of the output field to put the binary file data in',
},
],
},
];
const displayOptions = {
show: {
operation: ['download'],
resource: ['video'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const url = this.getNodeParameter('url', i, '') as string;
const binaryPropertyOutput = this.getNodeParameter(
'options.binaryPropertyOutput',
i,
'data',
) as string;
const credentials = await this.getCredentials('googlePalmApi');
const { fileContent, mimeType } = await downloadFile.call(this, url, 'video/mp4', {
key: credentials.apiKey as string,
});
const binaryData = await this.helpers.prepareBinaryData(fileContent, 'video.mp4', mimeType);
return [
{
binary: { [binaryPropertyOutput]: binaryData },
json: {
...binaryData,
data: undefined,
},
pairedItem: { item: i },
},
];
}
@@ -0,0 +1,212 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
import type { VeoResponse } from '../../helpers/interfaces';
import { downloadFile } from '../../helpers/utils';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
const properties: INodeProperties[] = [
modelRLC('videoGenerationModelSearch'),
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
placeholder: 'e.g. Panning wide shot of a calico kitten sleeping in the sunshine',
description: 'A text description of the desired video',
default: '',
typeOptions: {
rows: 2,
},
},
{
displayName: 'Return As',
name: 'returnAs',
type: 'options',
options: [
{
name: 'Video',
value: 'video',
},
{
name: 'URL',
value: 'url',
},
],
description:
'Whether to return the video as a binary file or a URL that can be used to download the video later',
default: 'video',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Number of Videos',
name: 'sampleCount',
type: 'number',
default: 1,
description: 'How many videos to generate',
typeOptions: {
minValue: 1,
maxValue: 4,
},
},
{
displayName: 'Duration (Seconds)',
name: 'durationSeconds',
type: 'number',
default: 8,
description: 'Length of the generated video in seconds. Supported only by certain models.',
typeOptions: {
minValue: 5,
maxValue: 8,
},
},
{
displayName: 'Aspect Ratio',
name: 'aspectRatio',
type: 'options',
options: [
{
name: 'Widescreen (16:9)',
value: '16:9',
description: 'Most common aspect ratio for televisions and monitors',
},
{
name: 'Portrait (9:16)',
value: '9:16',
description: 'Popular for short-form videos like YouTube Shorts',
},
],
default: '16:9',
},
{
displayName: 'Person Generation',
name: 'personGeneration',
type: 'options',
options: [
{
name: "Don't Allow",
value: 'dont_allow',
description: 'Prevent generation of people in the video',
},
{
name: 'Allow Adult',
value: 'allow_adult',
description: 'Allow generation of adult people in the video',
},
{
name: 'Allow All',
value: 'allow_all',
description: 'Allow generation of all people in the video',
},
],
default: 'dont_allow',
},
{
displayName: 'Put Output in Field',
name: 'binaryPropertyOutput',
type: 'string',
default: 'data',
hint: 'The name of the output field to put the binary file data in',
},
],
},
];
const displayOptions = {
show: {
operation: ['generate'],
resource: ['video'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
const prompt = this.getNodeParameter('prompt', i, '') as string;
const returnAs = this.getNodeParameter('returnAs', i, 'video');
const options = this.getNodeParameter('options', i, {});
const binaryPropertyOutput = this.getNodeParameter(
'options.binaryPropertyOutput',
i,
'data',
) as string;
const credentials = await this.getCredentials('googlePalmApi');
if (!model.includes('veo')) {
throw new NodeOperationError(
this.getNode(),
`Model ${model} is not supported for video generation. Please use a Veo model`,
{
description: 'Video generation is only supported by Veo models',
},
);
}
const body = {
instances: [
{
prompt,
},
],
parameters: {
aspectRatio: options.aspectRatio,
personGeneration: options.personGeneration,
sampleCount: options.sampleCount ?? 1,
durationSeconds: options.durationSeconds,
},
};
let response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:predictLongRunning`, {
body,
})) as VeoResponse;
while (!response.done) {
await new Promise((resolve) => setTimeout(resolve, 5000));
response = (await apiRequest.call(this, 'GET', `/v1beta/${response.name}`)) as VeoResponse;
}
if (response.error) {
throw new NodeOperationError(this.getNode(), response.error.message, {
description: 'Error generating video',
});
}
if (returnAs === 'video') {
const promises = response.response.generateVideoResponse.generatedSamples.map(
async (sample) => {
const { fileContent, mimeType } = await downloadFile.call(
this,
sample.video.uri,
'video/mp4',
{
key: credentials.apiKey as string,
},
);
const binaryData = await this.helpers.prepareBinaryData(fileContent, 'video.mp4', mimeType);
return {
binary: { [binaryPropertyOutput]: binaryData },
json: {
...binaryData,
data: undefined,
},
pairedItem: { item: i },
};
},
);
return await Promise.all(promises);
} else {
return response.response.generateVideoResponse.generatedSamples.map((sample) => ({
json: {
url: sample.video.uri,
},
pairedItem: { item: i },
}));
}
}
@@ -0,0 +1,45 @@
import type { INodeProperties } from 'n8n-workflow';
import * as analyze from './analyze.operation';
import * as download from './download.operation';
import * as generate from './generate.operation';
export { analyze, download, generate };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Analyze Video',
value: 'analyze',
action: 'Analyze video',
description: 'Take in videos and answer questions about them',
},
{
name: 'Generate a Video',
value: 'generate',
action: 'Generate a video',
description: 'Creates a video from a text prompt',
},
{
name: 'Download Video',
value: 'download',
action: 'Download a video',
description: 'Download a generated video from the Google Gemini API using a URL',
},
],
default: 'generate',
displayOptions: {
show: {
resource: ['video'],
},
},
},
...analyze.description,
...download.description,
...generate.description,
];