mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat(MiniMax Node): Add standalone MiniMax vendor node (#28748)
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import type { IExecuteFunctions, INodeType } from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import { versionDescription } from './actions/versionDescription';
|
||||
|
||||
export class MiniMax implements INodeType {
|
||||
description = versionDescription;
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as textToSpeech from './tts.operation';
|
||||
|
||||
export { textToSpeech };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['audio'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Text to Speech',
|
||||
value: 'textToSpeech',
|
||||
action: 'Convert text to speech',
|
||||
description: 'Generate speech audio from text input',
|
||||
},
|
||||
],
|
||||
default: 'textToSpeech',
|
||||
},
|
||||
...textToSpeech.description,
|
||||
];
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { T2AResponse } from '../../helpers/interfaces';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Speech 02 HD',
|
||||
value: 'speech-02-hd',
|
||||
description: 'Superior rhythm and stability with outstanding quality',
|
||||
},
|
||||
{
|
||||
name: 'Speech 02 Turbo',
|
||||
value: 'speech-02-turbo',
|
||||
description: 'Enhanced multilingual capabilities and performance',
|
||||
},
|
||||
{
|
||||
name: 'Speech 2.6 HD',
|
||||
value: 'speech-2.6-hd',
|
||||
description: 'HD model with outstanding prosody and cloning similarity',
|
||||
},
|
||||
{
|
||||
name: 'Speech 2.6 Turbo',
|
||||
value: 'speech-2.6-turbo',
|
||||
description: 'Turbo model with support for 40 languages',
|
||||
},
|
||||
{
|
||||
name: 'Speech 2.8 HD',
|
||||
value: 'speech-2.8-hd',
|
||||
description: 'Latest HD model with ultra-realistic quality and sound tags',
|
||||
},
|
||||
{
|
||||
name: 'Speech 2.8 Turbo',
|
||||
value: 'speech-2.8-turbo',
|
||||
description: 'Latest Turbo model with seamless speed and natural flow',
|
||||
},
|
||||
],
|
||||
default: 'speech-2.8-hd',
|
||||
description: 'The speech synthesis model to use',
|
||||
},
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'The text to convert to speech (max 10,000 characters)',
|
||||
placeholder: 'e.g. Hello, welcome to our service!',
|
||||
},
|
||||
{
|
||||
displayName: 'Voice ID',
|
||||
name: 'voiceId',
|
||||
type: 'string',
|
||||
default: 'English_Graceful_Lady',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'Voice ID to use for speech synthesis. Browse available voices in the <a href="https://platform.minimax.io/docs/faq/system-voice-id">MiniMax documentation</a>.',
|
||||
placeholder: 'e.g. English_Graceful_Lady',
|
||||
},
|
||||
{
|
||||
displayName: 'Download Audio',
|
||||
name: 'downloadAudio',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to download the generated audio as binary data. When disabled, only the audio URL is returned.',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Audio Format',
|
||||
name: 'audioFormat',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'MP3', value: 'mp3' },
|
||||
{ name: 'PCM', value: 'pcm' },
|
||||
{ name: 'FLAC', value: 'flac' },
|
||||
{ name: 'WAV', value: 'wav' },
|
||||
],
|
||||
default: 'mp3',
|
||||
description: 'Output audio format. WAV is only supported in non-streaming mode.',
|
||||
},
|
||||
{
|
||||
displayName: 'Emotion',
|
||||
name: 'emotion',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Angry', value: 'angry' },
|
||||
{ name: 'Calm', value: 'calm' },
|
||||
{ name: 'Disgusted', value: 'disgusted' },
|
||||
{ name: 'Fearful', value: 'fearful' },
|
||||
{ name: 'Happy', value: 'happy' },
|
||||
{ name: 'Sad', value: 'sad' },
|
||||
{ name: 'Surprised', value: 'surprised' },
|
||||
],
|
||||
default: 'calm',
|
||||
description:
|
||||
'Emotion for synthesized speech. By default the model auto-selects the most natural emotion.',
|
||||
},
|
||||
{
|
||||
displayName: 'Language Boost',
|
||||
name: 'languageBoost',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Arabic', value: 'Arabic' },
|
||||
{ name: 'Auto Detect', value: 'auto' },
|
||||
{ name: 'Chinese', value: 'Chinese' },
|
||||
{ name: 'English', value: 'English' },
|
||||
{ name: 'French', value: 'French' },
|
||||
{ name: 'German', value: 'German' },
|
||||
{ name: 'Indonesian', value: 'Indonesian' },
|
||||
{ name: 'Italian', value: 'Italian' },
|
||||
{ name: 'Japanese', value: 'Japanese' },
|
||||
{ name: 'Korean', value: 'Korean' },
|
||||
{ name: 'Portuguese', value: 'Portuguese' },
|
||||
{ name: 'Russian', value: 'Russian' },
|
||||
{ name: 'Spanish', value: 'Spanish' },
|
||||
{ name: 'Thai', value: 'Thai' },
|
||||
{ name: 'Turkish', value: 'Turkish' },
|
||||
{ name: 'Vietnamese', value: 'Vietnamese' },
|
||||
],
|
||||
default: 'auto',
|
||||
description: 'Enhance recognition for a specific language',
|
||||
},
|
||||
{
|
||||
displayName: 'Pitch',
|
||||
name: 'pitch',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: -12,
|
||||
maxValue: 12,
|
||||
},
|
||||
default: 0,
|
||||
description: 'Speech pitch adjustment (-12 to 12, 0 = original pitch)',
|
||||
},
|
||||
{
|
||||
displayName: 'Speed',
|
||||
name: 'speed',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0.5,
|
||||
maxValue: 2,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: 'Speech speed (0.5-2, higher = faster)',
|
||||
},
|
||||
{
|
||||
displayName: 'Volume',
|
||||
name: 'volume',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0.1,
|
||||
maxValue: 10,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: 'Speech volume (0.1-10, higher = louder)',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['audio'],
|
||||
operation: ['textToSpeech'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('modelId', itemIndex) as string;
|
||||
const text = this.getNodeParameter('text', itemIndex) as string;
|
||||
const voiceId = this.getNodeParameter('voiceId', itemIndex) as string;
|
||||
const downloadAudio = this.getNodeParameter('downloadAudio', itemIndex, true) as boolean;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject;
|
||||
|
||||
const audioFormat = (options.audioFormat as string) || 'mp3';
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
text,
|
||||
stream: false,
|
||||
output_format: 'url',
|
||||
voice_setting: {
|
||||
voice_id: voiceId,
|
||||
speed: (options.speed as number) ?? 1,
|
||||
vol: (options.volume as number) ?? 1,
|
||||
pitch: (options.pitch as number) ?? 0,
|
||||
},
|
||||
audio_setting: {
|
||||
format: audioFormat,
|
||||
},
|
||||
};
|
||||
|
||||
if (options.emotion) {
|
||||
(body.voice_setting as IDataObject).emotion = options.emotion;
|
||||
}
|
||||
|
||||
if (options.languageBoost) {
|
||||
body.language_boost = options.languageBoost;
|
||||
}
|
||||
|
||||
const response = (await apiRequest.call(this, 'POST', '/t2a_v2', {
|
||||
body,
|
||||
})) as T2AResponse;
|
||||
|
||||
if (response.base_resp?.status_code !== 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Text-to-speech failed: ${response.base_resp?.status_msg || 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const audioData = response.data?.audio;
|
||||
if (!audioData) {
|
||||
throw new NodeOperationError(this.getNode(), 'No audio data returned');
|
||||
}
|
||||
|
||||
const jsonData: IDataObject = {
|
||||
audioLength: response.extra_info?.audio_length,
|
||||
audioFormat: response.extra_info?.audio_format,
|
||||
audioSize: response.extra_info?.audio_size,
|
||||
wordCount: response.extra_info?.word_count,
|
||||
usageCharacters: response.extra_info?.usage_characters,
|
||||
};
|
||||
|
||||
if (downloadAudio) {
|
||||
const audioResponse = await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: audioData,
|
||||
encoding: 'arraybuffer',
|
||||
returnFullResponse: true,
|
||||
});
|
||||
|
||||
const mimeType = (audioResponse.headers?.['content-type'] as string) || `audio/${audioFormat}`;
|
||||
const binaryBuffer = Buffer.from(audioResponse.body as ArrayBuffer);
|
||||
const fileName = `speech.${audioFormat}`;
|
||||
const binaryData = await this.helpers.prepareBinaryData(binaryBuffer, fileName, mimeType);
|
||||
|
||||
return [
|
||||
{
|
||||
binary: { data: binaryData },
|
||||
json: jsonData,
|
||||
pairedItem: { item: itemIndex },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...jsonData, audioUrl: audioData },
|
||||
pairedItem: { item: itemIndex },
|
||||
},
|
||||
];
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { ImageGenerationResponse } from '../../helpers/interfaces';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Image-01',
|
||||
value: 'image-01',
|
||||
description: 'High-quality image generation with fine-grained details',
|
||||
},
|
||||
],
|
||||
default: 'image-01',
|
||||
description: 'The model to use for image generation',
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Text description of the image to generate (max 1500 characters)',
|
||||
placeholder: 'e.g. A serene mountain landscape at sunset with reflections in a lake',
|
||||
},
|
||||
{
|
||||
displayName: 'Aspect Ratio',
|
||||
name: 'aspectRatio',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: '1:1 (1024x1024)', value: '1:1' },
|
||||
{ name: '16:9 (1280x720)', value: '16:9' },
|
||||
{ name: '2:3 (832x1248)', value: '2:3' },
|
||||
{ name: '21:9 (1344x576)', value: '21:9' },
|
||||
{ name: '3:2 (1248x832)', value: '3:2' },
|
||||
{ name: '3:4 (864x1152)', value: '3:4' },
|
||||
{ name: '4:3 (1152x864)', value: '4:3' },
|
||||
{ name: '9:16 (720x1280)', value: '9:16' },
|
||||
],
|
||||
default: '1:1',
|
||||
description: 'Aspect ratio of the generated image',
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Images',
|
||||
name: 'numberOfImages',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 9,
|
||||
},
|
||||
default: 1,
|
||||
description: 'Number of images to generate per request (1-9)',
|
||||
},
|
||||
{
|
||||
displayName: 'Download Image',
|
||||
name: 'downloadImage',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to download the generated image as binary data. When disabled, only the image URL is returned.',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Prompt Optimizer',
|
||||
name: 'promptOptimizer',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to automatically optimize the prompt for better results',
|
||||
},
|
||||
{
|
||||
displayName: 'Seed',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'Random seed for reproducible outputs. Using the same seed and parameters produces the same image.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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) as string;
|
||||
const prompt = this.getNodeParameter('prompt', i) as string;
|
||||
const aspectRatio = this.getNodeParameter('aspectRatio', i) as string;
|
||||
const numberOfImages = this.getNodeParameter('numberOfImages', i, 1) as number;
|
||||
const downloadImage = this.getNodeParameter('downloadImage', i, true) as boolean;
|
||||
const options = this.getNodeParameter('options', i, {}) as {
|
||||
promptOptimizer?: boolean;
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
prompt,
|
||||
aspect_ratio: aspectRatio,
|
||||
n: numberOfImages,
|
||||
response_format: 'url',
|
||||
};
|
||||
|
||||
if (options.promptOptimizer !== undefined) {
|
||||
body.prompt_optimizer = options.promptOptimizer;
|
||||
}
|
||||
|
||||
if (options.seed !== undefined) {
|
||||
body.seed = options.seed;
|
||||
}
|
||||
|
||||
const response = (await apiRequest.call(this, 'POST', '/image_generation', {
|
||||
body,
|
||||
})) as ImageGenerationResponse;
|
||||
|
||||
if (response.base_resp?.status_code !== 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Image generation failed: ${response.base_resp?.status_msg || 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrls = response.data?.image_urls ?? [];
|
||||
if (imageUrls.length === 0) {
|
||||
throw new NodeOperationError(this.getNode(), 'No images were generated');
|
||||
}
|
||||
|
||||
const results: INodeExecutionData[] = [];
|
||||
|
||||
for (let idx = 0; idx < imageUrls.length; idx++) {
|
||||
const imageUrl = imageUrls[idx];
|
||||
|
||||
if (downloadImage) {
|
||||
const imageResponse = await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: imageUrl,
|
||||
encoding: 'arraybuffer',
|
||||
returnFullResponse: true,
|
||||
});
|
||||
|
||||
const contentType = (imageResponse.headers?.['content-type'] as string) || 'image/png';
|
||||
const fileContent = Buffer.from(imageResponse.body as ArrayBuffer);
|
||||
const ext = contentType.includes('jpeg') || contentType.includes('jpg') ? 'jpg' : 'png';
|
||||
const binaryData = await this.helpers.prepareBinaryData(
|
||||
fileContent,
|
||||
`image_${idx}.${ext}`,
|
||||
contentType,
|
||||
);
|
||||
|
||||
results.push({
|
||||
binary: { data: binaryData },
|
||||
json: { imageUrl },
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
json: { imageUrl },
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as generate from './generate.operation';
|
||||
|
||||
export { generate };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['image'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Generate an Image',
|
||||
value: 'generate',
|
||||
action: 'Generate an image',
|
||||
description: 'Create an image from a text prompt',
|
||||
},
|
||||
],
|
||||
default: 'generate',
|
||||
},
|
||||
...generate.description,
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
text: 'message';
|
||||
image: 'generate';
|
||||
video: 'textToVideo' | 'imageToVideo';
|
||||
audio: 'textToSpeech';
|
||||
};
|
||||
|
||||
export type MiniMaxType = AllEntities<NodeMap>;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NodeOperationError, type IExecuteFunctions, type INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import * as audio from './audio';
|
||||
import * as image from './image';
|
||||
import type { MiniMaxType } 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 miniMaxTypeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as MiniMaxType;
|
||||
|
||||
let execute;
|
||||
switch (miniMaxTypeData.resource) {
|
||||
case 'audio':
|
||||
execute = audio[miniMaxTypeData.operation].execute;
|
||||
break;
|
||||
case 'image':
|
||||
execute = image[miniMaxTypeData.operation].execute;
|
||||
break;
|
||||
case 'text':
|
||||
execute = text[miniMaxTypeData.operation].execute;
|
||||
break;
|
||||
case 'video':
|
||||
execute = video[miniMaxTypeData.operation].execute;
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(this.getNode(), `The resource "${resource}" 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: 'Send a message and get a response from a MiniMax model',
|
||||
},
|
||||
],
|
||||
default: 'message',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...message.description,
|
||||
];
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { accumulateTokenUsage, jsonParse, updateDisplayOptions } from 'n8n-workflow';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
|
||||
import { getConnectedTools } from '@utils/helpers';
|
||||
|
||||
import type {
|
||||
ChatCompletionResponse,
|
||||
ChatMessage,
|
||||
ToolCall,
|
||||
ToolFunction,
|
||||
} from '../../helpers/interfaces';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'MiniMax-M2', value: 'MiniMax-M2' },
|
||||
{ name: 'MiniMax-M2.1', value: 'MiniMax-M2.1' },
|
||||
{ name: 'MiniMax-M2.1-Highspeed', value: 'MiniMax-M2.1-highspeed' },
|
||||
{ name: 'MiniMax-M2.5', value: 'MiniMax-M2.5' },
|
||||
{ name: 'MiniMax-M2.5-Highspeed', value: 'MiniMax-M2.5-highspeed' },
|
||||
{ name: 'MiniMax-M2.7', value: 'MiniMax-M2.7' },
|
||||
{ name: 'MiniMax-M2.7-Highspeed', value: 'MiniMax-M2.7-highspeed' },
|
||||
],
|
||||
default: 'MiniMax-M2.7',
|
||||
description: 'The model to use for generating the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Messages',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
sortable: true,
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Message',
|
||||
default: { values: [{ content: '', role: 'user' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'content',
|
||||
type: 'string',
|
||||
description: 'The content of the message to be sent',
|
||||
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: 'Assistant',
|
||||
value: 'assistant',
|
||||
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: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Hide Thinking',
|
||||
name: 'hideThinking',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to strip chain-of-thought reasoning from the response, returning only the final answer',
|
||||
},
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: 1024,
|
||||
description: 'The maximum number of tokens to generate in the completion',
|
||||
type: 'number',
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Temperature)',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
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: 1,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Top P)',
|
||||
name: 'topP',
|
||||
default: 0.95,
|
||||
description: 'The maximum cumulative probability of tokens to consider when sampling',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'System Message',
|
||||
name: 'system',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. You are a helpful assistant',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['message'],
|
||||
resource: ['text'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
interface MessageOptions {
|
||||
hideThinking?: boolean;
|
||||
maxTokens?: number;
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
}
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('modelId', i) as string;
|
||||
const rawMessages = this.getNodeParameter('messages.values', i, []) as Array<{
|
||||
content: string;
|
||||
role: string;
|
||||
}>;
|
||||
const simplify = this.getNodeParameter('simplify', i, true) as boolean;
|
||||
const options = this.getNodeParameter('options', i, {}) as MessageOptions;
|
||||
|
||||
const hideThinking = options.hideThinking ?? true;
|
||||
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
if (options.system) {
|
||||
messages.push({ role: 'system', content: options.system });
|
||||
}
|
||||
|
||||
for (const msg of rawMessages) {
|
||||
messages.push({ role: msg.role as 'user' | 'assistant', content: msg.content });
|
||||
}
|
||||
|
||||
const { tools, connectedTools } = await getToolDefinitions.call(this);
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
messages,
|
||||
max_tokens: options.maxTokens ?? 1024,
|
||||
};
|
||||
|
||||
if (hideThinking) {
|
||||
body.reasoning_split = true;
|
||||
}
|
||||
|
||||
if (options.temperature !== undefined) body.temperature = options.temperature;
|
||||
if (options.topP !== undefined) body.top_p = options.topP;
|
||||
|
||||
if (tools.length > 0) {
|
||||
body.tools = tools;
|
||||
}
|
||||
|
||||
let response = (await apiRequest.call(this, 'POST', '/chat/completions', {
|
||||
body,
|
||||
})) as ChatCompletionResponse;
|
||||
|
||||
const captureUsage = () => {
|
||||
const usage = response.usage;
|
||||
if (usage) {
|
||||
accumulateTokenUsage(this, usage.prompt_tokens, usage.completion_tokens);
|
||||
}
|
||||
};
|
||||
|
||||
captureUsage();
|
||||
|
||||
const maxToolsIterations = this.getNodeParameter('options.maxToolsIterations', i, 15) as number;
|
||||
const abortSignal = this.getExecutionCancelSignal();
|
||||
let currentIteration = 0;
|
||||
|
||||
while (true) {
|
||||
if (abortSignal?.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
const choice = response.choices?.[0];
|
||||
if (choice?.finish_reason !== 'tool_calls' || !choice.message.tool_calls?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (maxToolsIterations > 0 && currentIteration >= maxToolsIterations) {
|
||||
break;
|
||||
}
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: choice.message.content ?? '',
|
||||
tool_calls: choice.message.tool_calls,
|
||||
};
|
||||
if (choice.message.reasoning_content) {
|
||||
assistantMsg.reasoning_content = choice.message.reasoning_content;
|
||||
}
|
||||
messages.push(assistantMsg);
|
||||
|
||||
await handleToolUse.call(this, choice.message.tool_calls, messages, connectedTools);
|
||||
currentIteration++;
|
||||
|
||||
response = (await apiRequest.call(this, 'POST', '/chat/completions', {
|
||||
body,
|
||||
})) as ChatCompletionResponse;
|
||||
|
||||
captureUsage();
|
||||
}
|
||||
|
||||
const finalMessage = response.choices?.[0]?.message;
|
||||
|
||||
if (simplify) {
|
||||
const result: IDataObject = {
|
||||
content: finalMessage?.content ?? '',
|
||||
};
|
||||
|
||||
if (!hideThinking && finalMessage?.reasoning_content) {
|
||||
result.reasoning_content = finalMessage.reasoning_content;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: result,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function getToolDefinitions(this: IExecuteFunctions) {
|
||||
let connectedTools: Tool[] = [];
|
||||
const nodeInputs = this.getNodeInputs();
|
||||
|
||||
if (nodeInputs.some((input) => input.type === 'ai_tool')) {
|
||||
connectedTools = await getConnectedTools(this, true);
|
||||
}
|
||||
|
||||
const tools: ToolFunction[] = connectedTools.map((t) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: zodToJsonSchema(t.schema) as IDataObject,
|
||||
},
|
||||
}));
|
||||
|
||||
return { tools, connectedTools };
|
||||
}
|
||||
|
||||
async function handleToolUse(
|
||||
this: IExecuteFunctions,
|
||||
toolCalls: ToolCall[],
|
||||
messages: ChatMessage[],
|
||||
connectedTools: Tool[],
|
||||
) {
|
||||
for (const toolCall of toolCalls) {
|
||||
let toolResponse: unknown;
|
||||
for (const connectedTool of connectedTools) {
|
||||
if (connectedTool.name === toolCall.function.name) {
|
||||
const args = jsonParse<IDataObject>(toolCall.function.arguments);
|
||||
toolResponse = await connectedTool.invoke(args);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content:
|
||||
typeof toolResponse === 'object'
|
||||
? JSON.stringify(toolResponse)
|
||||
: ((toolResponse as string) ?? ''),
|
||||
tool_call_id: toolCall.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as audio from './audio';
|
||||
import * as image from './image';
|
||||
import * as text from './text';
|
||||
import * as video from './video';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'MiniMax',
|
||||
name: 'minimax',
|
||||
icon: 'file:minimax.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
|
||||
description: 'Interact with MiniMax AI models',
|
||||
defaults: {
|
||||
name: 'MiniMax',
|
||||
},
|
||||
usableAsTool: true,
|
||||
codex: {
|
||||
alias: ['minimax', 'hailuo', 'LangChain', 'video', 'image', 'tts', 'speech'],
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Agents', 'Miscellaneous', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.minimax/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
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: 'minimaxApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Audio',
|
||||
value: 'audio',
|
||||
},
|
||||
{
|
||||
name: 'Image',
|
||||
value: 'image',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Video',
|
||||
value: 'video',
|
||||
},
|
||||
],
|
||||
default: 'text',
|
||||
},
|
||||
...audio.description,
|
||||
...image.description,
|
||||
...text.description,
|
||||
...video.description,
|
||||
],
|
||||
};
|
||||
Vendored
+380
@@ -0,0 +1,380 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { VideoGenerationResponse } from '../../helpers/interfaces';
|
||||
import { apiRequest, getVideoDownloadUrl, pollVideoTask } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'I2V-01',
|
||||
value: 'I2V-01',
|
||||
description: 'Standard image-to-video model',
|
||||
},
|
||||
{
|
||||
name: 'I2V-01-Director',
|
||||
value: 'I2V-01-Director',
|
||||
description: 'Image-to-video with camera control commands',
|
||||
},
|
||||
{
|
||||
name: 'I2V-01-Live',
|
||||
value: 'I2V-01-live',
|
||||
description: 'Image-to-video live model',
|
||||
},
|
||||
{
|
||||
name: 'MiniMax-Hailuo-02',
|
||||
value: 'MiniMax-Hailuo-02',
|
||||
description: 'Model supporting higher resolution and longer duration',
|
||||
},
|
||||
{
|
||||
name: 'MiniMax-Hailuo-2.3',
|
||||
value: 'MiniMax-Hailuo-2.3',
|
||||
description: 'Latest model with enhanced realism',
|
||||
},
|
||||
{
|
||||
name: 'MiniMax-Hailuo-2.3-Fast',
|
||||
value: 'MiniMax-Hailuo-2.3-Fast',
|
||||
description: 'Faster image-to-video model for value and efficiency',
|
||||
},
|
||||
],
|
||||
default: 'MiniMax-Hailuo-2.3',
|
||||
description: 'The model to use for video generation',
|
||||
},
|
||||
{
|
||||
displayName: 'Image Input Type',
|
||||
name: 'imageInputType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'URL', value: 'url' },
|
||||
{ name: 'Binary File', value: 'binary' },
|
||||
],
|
||||
default: 'url',
|
||||
description: 'How to provide the first frame image',
|
||||
},
|
||||
{
|
||||
displayName: 'Image URL',
|
||||
name: 'imageUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'https://example.com/image.jpg',
|
||||
description: 'Public URL of the image to use as first frame (JPG, JPEG, PNG, WebP, <20MB)',
|
||||
displayOptions: {
|
||||
show: {
|
||||
imageInputType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
required: true,
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary image data',
|
||||
typeOptions: {
|
||||
binaryDataProperty: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
imageInputType: ['binary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
'Optional text description of the video (max 2000 characters). Camera movements can be controlled using [command] syntax.',
|
||||
placeholder: 'e.g. The subject smiles and waves at the camera [Zoom in]',
|
||||
},
|
||||
{
|
||||
displayName: 'Duration (Seconds)',
|
||||
name: 'duration',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: '6 Seconds', value: 6 },
|
||||
{ name: '10 Seconds', value: 10 },
|
||||
],
|
||||
default: 6,
|
||||
description: 'Duration of the generated video',
|
||||
},
|
||||
{
|
||||
displayName: 'Resolution',
|
||||
name: 'resolution',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: '512P', value: '512P' },
|
||||
{ name: '720P', value: '720P' },
|
||||
{ name: '768P', value: '768P' },
|
||||
{ name: '1080P', value: '1080P' },
|
||||
],
|
||||
default: '768P',
|
||||
description: 'Resolution of the generated video. Available options depend on the model.',
|
||||
},
|
||||
{
|
||||
displayName: 'Download Video',
|
||||
name: 'downloadVideo',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to download the generated video as binary data. When disabled, only the video URL is returned.',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Prompt Optimizer',
|
||||
name: 'promptOptimizer',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to automatically optimize the prompt',
|
||||
},
|
||||
{
|
||||
displayName: 'Last Frame Image Input Type',
|
||||
name: 'lastFrameInputType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'None', value: 'none' },
|
||||
{ name: 'URL', value: 'url' },
|
||||
{ name: 'Binary File', value: 'binary' },
|
||||
],
|
||||
default: 'none',
|
||||
description:
|
||||
'Provide a last frame image to generate a first-and-last-frame video. Only supported by MiniMax-Hailuo-2.3 and MiniMax-Hailuo-02.',
|
||||
},
|
||||
{
|
||||
displayName: 'Last Frame Image URL',
|
||||
name: 'lastFrameImageUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'https://example.com/last-frame.jpg',
|
||||
displayOptions: {
|
||||
show: {
|
||||
lastFrameInputType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Last Frame Data Field Name',
|
||||
name: 'lastFrameBinaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'lastFrame',
|
||||
placeholder: 'e.g. lastFrame',
|
||||
typeOptions: {
|
||||
binaryDataProperty: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
lastFrameInputType: ['binary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subject Reference Input Type',
|
||||
name: 'subjectReferenceInputType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'None', value: 'none' },
|
||||
{ name: 'URL', value: 'url' },
|
||||
{ name: 'Binary File', value: 'binary' },
|
||||
],
|
||||
default: 'none',
|
||||
description:
|
||||
'Provide a face photo for facial consistency in the generated video. Only supported by MiniMax-Hailuo-2.3.',
|
||||
},
|
||||
{
|
||||
displayName: 'Subject Reference Image URL',
|
||||
name: 'subjectReferenceImageUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'https://example.com/face.jpg',
|
||||
displayOptions: {
|
||||
show: {
|
||||
subjectReferenceInputType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subject Reference Data Field Name',
|
||||
name: 'subjectReferenceBinaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'subjectReference',
|
||||
placeholder: 'e.g. subjectReference',
|
||||
typeOptions: {
|
||||
binaryDataProperty: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
subjectReferenceInputType: ['binary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['video'],
|
||||
operation: ['imageToVideo'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
async function resolveImageInput(
|
||||
executeFunctions: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
inputType: string,
|
||||
urlValue: string,
|
||||
binaryPropertyName: string,
|
||||
): Promise<string> {
|
||||
if (inputType === 'binary') {
|
||||
const binaryData = executeFunctions.helpers.assertBinaryData(itemIndex, binaryPropertyName);
|
||||
const buffer = await executeFunctions.helpers.getBinaryDataBuffer(
|
||||
itemIndex,
|
||||
binaryPropertyName,
|
||||
);
|
||||
return `data:${binaryData.mimeType};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
return urlValue;
|
||||
}
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('modelId', itemIndex) as string;
|
||||
const imageInputType = this.getNodeParameter('imageInputType', itemIndex) as string;
|
||||
const prompt = this.getNodeParameter('prompt', itemIndex, '') as string;
|
||||
const duration = this.getNodeParameter('duration', itemIndex) as number;
|
||||
const resolution = this.getNodeParameter('resolution', itemIndex) as string;
|
||||
const downloadVideo = this.getNodeParameter('downloadVideo', itemIndex, true) as boolean;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject;
|
||||
|
||||
let firstFrameImage: string;
|
||||
if (imageInputType === 'binary') {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', itemIndex) as string;
|
||||
firstFrameImage = await resolveImageInput(this, itemIndex, 'binary', '', binaryPropertyName);
|
||||
} else {
|
||||
const imageUrl = this.getNodeParameter('imageUrl', itemIndex) as string;
|
||||
firstFrameImage = imageUrl;
|
||||
}
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
first_frame_image: firstFrameImage,
|
||||
duration,
|
||||
resolution,
|
||||
};
|
||||
|
||||
if (prompt) {
|
||||
body.prompt = prompt;
|
||||
}
|
||||
|
||||
if (options.promptOptimizer !== undefined) {
|
||||
body.prompt_optimizer = options.promptOptimizer;
|
||||
}
|
||||
|
||||
const lastFrameInputType = (options.lastFrameInputType as string) || 'none';
|
||||
if (lastFrameInputType !== 'none') {
|
||||
body.last_frame_image = await resolveImageInput(
|
||||
this,
|
||||
itemIndex,
|
||||
lastFrameInputType,
|
||||
(options.lastFrameImageUrl as string) || '',
|
||||
(options.lastFrameBinaryPropertyName as string) || 'lastFrame',
|
||||
);
|
||||
}
|
||||
|
||||
const subjectRefInputType = (options.subjectReferenceInputType as string) || 'none';
|
||||
if (subjectRefInputType !== 'none') {
|
||||
body.subject_reference = [
|
||||
{
|
||||
image: await resolveImageInput(
|
||||
this,
|
||||
itemIndex,
|
||||
subjectRefInputType,
|
||||
(options.subjectReferenceImageUrl as string) || '',
|
||||
(options.subjectReferenceBinaryPropertyName as string) || 'subjectReference',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const createResponse = (await apiRequest.call(this, 'POST', '/video_generation', {
|
||||
body,
|
||||
})) as VideoGenerationResponse;
|
||||
|
||||
if (createResponse.base_resp?.status_code !== 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Failed to create video task: ${createResponse.base_resp?.status_msg || 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const taskId = createResponse.task_id;
|
||||
if (!taskId) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'No task_id returned from video generation request',
|
||||
);
|
||||
}
|
||||
|
||||
const { fileId } = await pollVideoTask.call(this, taskId);
|
||||
const videoUrl = await getVideoDownloadUrl.call(this, fileId);
|
||||
|
||||
const jsonData: IDataObject = {
|
||||
videoUrl,
|
||||
taskId,
|
||||
fileId,
|
||||
};
|
||||
|
||||
if (downloadVideo && videoUrl) {
|
||||
const videoResponse = await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: videoUrl,
|
||||
encoding: 'arraybuffer',
|
||||
returnFullResponse: true,
|
||||
});
|
||||
|
||||
const contentType = (videoResponse.headers?.['content-type'] as string) || 'video/mp4';
|
||||
const fileContent = Buffer.from(videoResponse.body as ArrayBuffer);
|
||||
const binaryData = await this.helpers.prepareBinaryData(fileContent, 'video.mp4', contentType);
|
||||
|
||||
return [
|
||||
{
|
||||
binary: { data: binaryData },
|
||||
json: jsonData,
|
||||
pairedItem: { item: itemIndex },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: jsonData,
|
||||
pairedItem: { item: itemIndex },
|
||||
},
|
||||
];
|
||||
}
|
||||
Vendored
+192
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { VideoGenerationResponse } from '../../helpers/interfaces';
|
||||
import { apiRequest, getVideoDownloadUrl, pollVideoTask } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'MiniMax-Hailuo-2.3',
|
||||
value: 'MiniMax-Hailuo-2.3',
|
||||
description: 'Latest video generation model with enhanced realism',
|
||||
},
|
||||
{
|
||||
name: 'MiniMax-Hailuo-02',
|
||||
value: 'MiniMax-Hailuo-02',
|
||||
description: 'Video model supporting higher resolution and longer duration',
|
||||
},
|
||||
{
|
||||
name: 'T2V-01-Director',
|
||||
value: 'T2V-01-Director',
|
||||
description: 'Text-to-video model with camera control commands',
|
||||
},
|
||||
{
|
||||
name: 'T2V-01',
|
||||
value: 'T2V-01',
|
||||
description: 'Standard text-to-video model',
|
||||
},
|
||||
],
|
||||
default: 'MiniMax-Hailuo-2.3',
|
||||
description: 'The model to use for video generation',
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description:
|
||||
'Text description of the video (max 2000 characters). Camera movements can be controlled using [command] syntax, e.g. [Push in], [Pan left].',
|
||||
placeholder: 'e.g. A cat playing with a ball of yarn [Static shot]',
|
||||
},
|
||||
{
|
||||
displayName: 'Duration (Seconds)',
|
||||
name: 'duration',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: '6 Seconds', value: 6 },
|
||||
{ name: '10 Seconds', value: 10 },
|
||||
],
|
||||
default: 6,
|
||||
description: 'Duration of the generated video',
|
||||
},
|
||||
{
|
||||
displayName: 'Resolution',
|
||||
name: 'resolution',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: '720P', value: '720P' },
|
||||
{ name: '768P', value: '768P' },
|
||||
{ name: '1080P', value: '1080P' },
|
||||
],
|
||||
default: '768P',
|
||||
description: 'Resolution of the generated video. Available options depend on the model.',
|
||||
},
|
||||
{
|
||||
displayName: 'Download Video',
|
||||
name: 'downloadVideo',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to download the generated video as binary data. When disabled, only the video URL is returned.',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Prompt Optimizer',
|
||||
name: 'promptOptimizer',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to automatically optimize the prompt for better results',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['video'],
|
||||
operation: ['textToVideo'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('modelId', itemIndex) as string;
|
||||
const prompt = this.getNodeParameter('prompt', itemIndex) as string;
|
||||
const duration = this.getNodeParameter('duration', itemIndex) as number;
|
||||
const resolution = this.getNodeParameter('resolution', itemIndex) as string;
|
||||
const downloadVideo = this.getNodeParameter('downloadVideo', itemIndex, true) as boolean;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
promptOptimizer?: boolean;
|
||||
};
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
prompt,
|
||||
duration,
|
||||
resolution,
|
||||
};
|
||||
|
||||
if (options.promptOptimizer !== undefined) {
|
||||
body.prompt_optimizer = options.promptOptimizer;
|
||||
}
|
||||
|
||||
const createResponse = (await apiRequest.call(this, 'POST', '/video_generation', {
|
||||
body,
|
||||
})) as VideoGenerationResponse;
|
||||
|
||||
if (createResponse.base_resp?.status_code !== 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Failed to create video task: ${createResponse.base_resp?.status_msg || 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const taskId = createResponse.task_id;
|
||||
if (!taskId) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'No task_id returned from video generation request',
|
||||
);
|
||||
}
|
||||
|
||||
const { fileId } = await pollVideoTask.call(this, taskId);
|
||||
const videoUrl = await getVideoDownloadUrl.call(this, fileId);
|
||||
|
||||
const jsonData: IDataObject = {
|
||||
videoUrl,
|
||||
taskId,
|
||||
fileId,
|
||||
};
|
||||
|
||||
if (downloadVideo && videoUrl) {
|
||||
const videoResponse = await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: videoUrl,
|
||||
encoding: 'arraybuffer',
|
||||
returnFullResponse: true,
|
||||
});
|
||||
|
||||
const contentType = (videoResponse.headers?.['content-type'] as string) || 'video/mp4';
|
||||
const fileContent = Buffer.from(videoResponse.body as ArrayBuffer);
|
||||
const binaryData = await this.helpers.prepareBinaryData(fileContent, 'video.mp4', contentType);
|
||||
|
||||
return [
|
||||
{
|
||||
binary: { data: binaryData },
|
||||
json: jsonData,
|
||||
pairedItem: { item: itemIndex },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: jsonData,
|
||||
pairedItem: { item: itemIndex },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as textToVideo from './generate.t2v.operation';
|
||||
import * as imageToVideo from './generate.i2v.operation';
|
||||
|
||||
export { textToVideo, imageToVideo };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['video'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Generate Video From Text',
|
||||
value: 'textToVideo',
|
||||
action: 'Generate video from text prompt',
|
||||
description: 'Generate a video from a text prompt',
|
||||
},
|
||||
{
|
||||
name: 'Generate Video From Image',
|
||||
value: 'imageToVideo',
|
||||
action: 'Generate video from image',
|
||||
description:
|
||||
'Generate a video from an image, with optional last frame and subject reference',
|
||||
},
|
||||
],
|
||||
default: 'textToVideo',
|
||||
},
|
||||
...textToVideo.description,
|
||||
...imageToVideo.description,
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
reasoning_content?: string;
|
||||
}
|
||||
|
||||
export interface ToolFunction {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: IDataObject;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string | null;
|
||||
reasoning_content?: string | null;
|
||||
tool_calls?: ToolCall[];
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
id: string;
|
||||
data: {
|
||||
image_urls?: string[];
|
||||
image_base64?: string[];
|
||||
};
|
||||
metadata: {
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
};
|
||||
base_resp: {
|
||||
status_code: number;
|
||||
status_msg: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VideoGenerationResponse {
|
||||
task_id: string;
|
||||
base_resp: {
|
||||
status_code: number;
|
||||
status_msg: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface T2AResponse {
|
||||
data: {
|
||||
audio: string;
|
||||
status: number;
|
||||
};
|
||||
extra_info: {
|
||||
audio_length: number;
|
||||
audio_sample_rate: number;
|
||||
audio_size: number;
|
||||
bitrate: number;
|
||||
audio_format: string;
|
||||
audio_channel: number;
|
||||
usage_characters: number;
|
||||
word_count: number;
|
||||
};
|
||||
trace_id: string;
|
||||
base_resp: {
|
||||
status_code: number;
|
||||
status_msg: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="40" height="40" viewBox="0 0 490.16 411.7" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="minimax-grad" y1="205.85" x2="490.16" y2="205.85" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#e4177f"/>
|
||||
<stop offset="0.5" stop-color="#e73562"/>
|
||||
<stop offset="1" stop-color="#e94e4a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#minimax-grad)" d="M233.45,40.81a17.55,17.55,0,1,0-35.1,0V331.56a40.82,40.82,0,0,1-81.63,0V145a17.55,17.55,0,1,0-35.09,0v79.06a40.82,40.82,0,0,1-81.63,0V195.42a11.63,11.63,0,0,1,23.26,0v28.66a17.55,17.55,0,0,0,35.1,0V145A40.82,40.82,0,0,1,140,145V331.56a17.55,17.55,0,0,0,35.1,0V217.5h0V40.81a40.81,40.81,0,1,1,81.62,0V281.56a11.63,11.63,0,1,1-23.26,0Zm215.9,63.4A40.86,40.86,0,0,0,408.53,145V300.85a17.55,17.55,0,0,1-35.09,0v-260a40.82,40.82,0,0,0-81.63,0V370.89a17.55,17.55,0,0,1-35.1,0V330a11.63,11.63,0,1,0-23.26,0v40.86a40.81,40.81,0,0,0,81.62,0V40.81a17.55,17.55,0,0,1,35.1,0v260a40.82,40.82,0,0,0,81.63,0V145a17.55,17.55,0,1,1,35.1,0V281.56a11.63,11.63,0,0,0,23.26,0V145A40.85,40.85,0,0,0,449.35,104.21Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,429 @@
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, IBinaryData } from 'n8n-workflow';
|
||||
|
||||
jest.mock('../transport', () => ({
|
||||
apiRequest: jest.fn(),
|
||||
pollVideoTask: jest.fn(),
|
||||
getVideoDownloadUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/helpers', () => ({
|
||||
getConnectedTools: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('zod-to-json-schema', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('n8n-workflow', () => {
|
||||
const actual = jest.requireActual('n8n-workflow');
|
||||
return {
|
||||
...actual,
|
||||
accumulateTokenUsage: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { execute as textMessageExecute } from '../actions/text/message.operation';
|
||||
import { execute as imageGenerateExecute } from '../actions/image/generate.operation';
|
||||
import { execute as videoT2VExecute } from '../actions/video/generate.t2v.operation';
|
||||
import { execute as videoI2VExecute } from '../actions/video/generate.i2v.operation';
|
||||
import { execute as audioTTSExecute } from '../actions/audio/tts.operation';
|
||||
import { apiRequest, pollVideoTask, getVideoDownloadUrl } from '../transport';
|
||||
|
||||
const mockApiRequest = apiRequest as jest.Mock;
|
||||
const mockPollVideoTask = pollVideoTask as jest.Mock;
|
||||
const mockGetVideoDownloadUrl = getVideoDownloadUrl as jest.Mock;
|
||||
|
||||
describe('MiniMax Operations', () => {
|
||||
let mockExecuteFunctions: ReturnType<typeof mock<IExecuteFunctions>>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockExecuteFunctions.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
||||
mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Text: message', () => {
|
||||
it('should send correct request body and return simplified response', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'MiniMax-M2.7',
|
||||
'messages.values': [{ role: 'user', content: 'Hello' }],
|
||||
options: { temperature: 0.7 },
|
||||
simplify: true,
|
||||
'options.maxToolsIterations': 15,
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
choices: [{ message: { content: 'Hi there!' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
|
||||
};
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await textMessageExecute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/chat/completions', {
|
||||
body: expect.objectContaining({
|
||||
model: 'MiniMax-M2.7',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
reasoning_split: true,
|
||||
}),
|
||||
});
|
||||
expect(result[0].json).toEqual({ content: 'Hi there!' });
|
||||
});
|
||||
|
||||
it('should return full response when simplify is false', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'MiniMax-M2.7',
|
||||
'messages.values': [{ role: 'user', content: 'Hello' }],
|
||||
options: {},
|
||||
simplify: false,
|
||||
'options.maxToolsIterations': 15,
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
choices: [{ message: { content: 'Hi!' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
};
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await textMessageExecute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result[0].json).toEqual(
|
||||
expect.objectContaining({
|
||||
choices: expect.any(Array),
|
||||
usage: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include system message when provided', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'MiniMax-M2.7',
|
||||
'messages.values': [{ role: 'user', content: 'Hello' }],
|
||||
options: { system: 'You are a helpful assistant' },
|
||||
simplify: true,
|
||||
'options.maxToolsIterations': 15,
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
choices: [{ message: { content: 'Hi!' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
|
||||
};
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
|
||||
await textMessageExecute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/chat/completions', {
|
||||
body: expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
{ role: 'system', content: 'You are a helpful assistant' },
|
||||
]),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Image: generate', () => {
|
||||
it('should send prompt and return URL-only when downloadImage is false', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'image-01',
|
||||
prompt: 'A sunset over mountains',
|
||||
aspectRatio: '16:9',
|
||||
numberOfImages: 1,
|
||||
downloadImage: false,
|
||||
options: {},
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
data: { image_urls: ['https://cdn.minimax.io/image.png'] },
|
||||
metadata: { success_count: 1, failed_count: 0 },
|
||||
base_resp: { status_code: 0, status_msg: 'success' },
|
||||
};
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await imageGenerateExecute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/image_generation', {
|
||||
body: expect.objectContaining({
|
||||
model: 'image-01',
|
||||
prompt: 'A sunset over mountains',
|
||||
aspect_ratio: '16:9',
|
||||
n: 1,
|
||||
}),
|
||||
});
|
||||
expect(result[0].json).toEqual({ imageUrl: 'https://cdn.minimax.io/image.png' });
|
||||
expect(result[0].binary).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should download image as binary when downloadImage is true', async () => {
|
||||
const deepMock = mockDeep<IExecuteFunctions>();
|
||||
deepMock.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'image-01',
|
||||
prompt: 'A sunset',
|
||||
aspectRatio: '1:1',
|
||||
numberOfImages: 1,
|
||||
downloadImage: true,
|
||||
options: {},
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
data: { image_urls: ['https://cdn.minimax.io/image.png'] },
|
||||
metadata: { success_count: 1, failed_count: 0 },
|
||||
base_resp: { status_code: 0, status_msg: 'success' },
|
||||
};
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
|
||||
const imageBuffer = Buffer.from('fake-png-data');
|
||||
deepMock.helpers.httpRequest.mockResolvedValue({
|
||||
body: imageBuffer,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
});
|
||||
|
||||
const mockBinaryData: IBinaryData = {
|
||||
mimeType: 'image/png',
|
||||
fileType: 'image',
|
||||
fileExtension: 'png',
|
||||
data: '',
|
||||
fileName: 'image_0.png',
|
||||
};
|
||||
deepMock.helpers.prepareBinaryData.mockResolvedValue(mockBinaryData);
|
||||
|
||||
const result = await imageGenerateExecute.call(deepMock, 0);
|
||||
|
||||
expect(deepMock.helpers.httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
url: 'https://cdn.minimax.io/image.png',
|
||||
encoding: 'arraybuffer',
|
||||
returnFullResponse: true,
|
||||
}),
|
||||
);
|
||||
expect(result[0].binary).toBeDefined();
|
||||
expect(result[0].binary!.data).toEqual(mockBinaryData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Video: textToVideo', () => {
|
||||
it('should create task, poll until success, and return video URL', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'MiniMax-Hailuo-2.3',
|
||||
prompt: 'A cat playing with yarn',
|
||||
duration: 6,
|
||||
resolution: '768P',
|
||||
downloadVideo: false,
|
||||
options: {},
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
mockApiRequest.mockResolvedValue({
|
||||
task_id: 'video-task-1',
|
||||
base_resp: { status_code: 0, status_msg: 'success' },
|
||||
});
|
||||
|
||||
mockPollVideoTask.mockResolvedValue({ fileId: 'file-abc', status: 'Success' });
|
||||
mockGetVideoDownloadUrl.mockResolvedValue('https://cdn.minimax.io/video.mp4');
|
||||
|
||||
const result = await videoT2VExecute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/video_generation', {
|
||||
body: expect.objectContaining({
|
||||
model: 'MiniMax-Hailuo-2.3',
|
||||
prompt: 'A cat playing with yarn',
|
||||
duration: 6,
|
||||
resolution: '768P',
|
||||
}),
|
||||
});
|
||||
expect(mockPollVideoTask).toHaveBeenCalledWith('video-task-1');
|
||||
expect(result[0].json).toEqual(
|
||||
expect.objectContaining({
|
||||
videoUrl: 'https://cdn.minimax.io/video.mp4',
|
||||
taskId: 'video-task-1',
|
||||
fileId: 'file-abc',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Video: imageToVideo', () => {
|
||||
it('should create task with image URL input and return video URL', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'MiniMax-Hailuo-2.3',
|
||||
imageInputType: 'url',
|
||||
imageUrl: 'https://example.com/frame.png',
|
||||
prompt: 'A bird taking flight',
|
||||
duration: 6,
|
||||
resolution: '768P',
|
||||
downloadVideo: false,
|
||||
options: {},
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
mockApiRequest.mockResolvedValue({
|
||||
task_id: 'i2v-task-1',
|
||||
base_resp: { status_code: 0, status_msg: 'success' },
|
||||
});
|
||||
|
||||
mockPollVideoTask.mockResolvedValue({ fileId: 'file-i2v', status: 'Success' });
|
||||
mockGetVideoDownloadUrl.mockResolvedValue('https://cdn.minimax.io/i2v-video.mp4');
|
||||
|
||||
const result = await videoI2VExecute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/video_generation', {
|
||||
body: expect.objectContaining({
|
||||
model: 'MiniMax-Hailuo-2.3',
|
||||
first_frame_image: 'https://example.com/frame.png',
|
||||
prompt: 'A bird taking flight',
|
||||
}),
|
||||
});
|
||||
expect(result[0].json).toEqual(
|
||||
expect.objectContaining({
|
||||
videoUrl: 'https://cdn.minimax.io/i2v-video.mp4',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audio: textToSpeech', () => {
|
||||
it('should send TTS request and return audio URL when downloadAudio is false', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'speech-2.8-hd',
|
||||
text: 'Hello world',
|
||||
voiceId: 'English_Graceful_Lady',
|
||||
downloadAudio: false,
|
||||
options: {},
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
data: { audio: 'https://cdn.minimax.io/speech.mp3', status: 1 },
|
||||
extra_info: {
|
||||
audio_length: 1500,
|
||||
audio_format: 'mp3',
|
||||
audio_size: 24000,
|
||||
word_count: 2,
|
||||
usage_characters: 11,
|
||||
},
|
||||
base_resp: { status_code: 0, status_msg: 'success' },
|
||||
};
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await audioTTSExecute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/t2a_v2', {
|
||||
body: expect.objectContaining({
|
||||
model: 'speech-2.8-hd',
|
||||
text: 'Hello world',
|
||||
voice_setting: expect.objectContaining({
|
||||
voice_id: 'English_Graceful_Lady',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(result[0].json).toEqual(
|
||||
expect.objectContaining({
|
||||
audioUrl: 'https://cdn.minimax.io/speech.mp3',
|
||||
audioLength: 1500,
|
||||
audioFormat: 'mp3',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should download audio as binary when downloadAudio is true', async () => {
|
||||
const deepMock = mockDeep<IExecuteFunctions>();
|
||||
deepMock.getNodeParameter.mockImplementation(
|
||||
(param: string, _index: number, fallback?: any) => {
|
||||
const params: Record<string, unknown> = {
|
||||
modelId: 'speech-2.8-hd',
|
||||
text: 'Hello world',
|
||||
voiceId: 'English_Graceful_Lady',
|
||||
downloadAudio: true,
|
||||
options: {},
|
||||
};
|
||||
return params[param] ?? fallback;
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
data: { audio: 'https://cdn.minimax.io/speech.mp3', status: 1 },
|
||||
extra_info: {
|
||||
audio_length: 1500,
|
||||
audio_format: 'mp3',
|
||||
audio_size: 24000,
|
||||
word_count: 2,
|
||||
usage_characters: 11,
|
||||
},
|
||||
base_resp: { status_code: 0, status_msg: 'success' },
|
||||
};
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
|
||||
const audioBuffer = Buffer.from('fake-audio-data');
|
||||
deepMock.helpers.httpRequest.mockResolvedValue({
|
||||
body: audioBuffer,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
});
|
||||
|
||||
const mockBinaryData: IBinaryData = {
|
||||
mimeType: 'audio/mpeg',
|
||||
fileType: 'audio',
|
||||
fileExtension: 'mp3',
|
||||
data: '',
|
||||
fileName: 'speech.mp3',
|
||||
};
|
||||
deepMock.helpers.prepareBinaryData.mockResolvedValue(mockBinaryData);
|
||||
|
||||
const result = await audioTTSExecute.call(deepMock, 0);
|
||||
|
||||
expect(deepMock.helpers.httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
url: 'https://cdn.minimax.io/speech.mp3',
|
||||
encoding: 'arraybuffer',
|
||||
returnFullResponse: true,
|
||||
}),
|
||||
);
|
||||
expect(result[0].binary).toBeDefined();
|
||||
expect(result[0].binary!.data).toEqual(mockBinaryData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
jest.mock('../actions/text', () => ({
|
||||
message: { execute: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../actions/image', () => ({
|
||||
generate: { execute: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../actions/video', () => ({
|
||||
textToVideo: { execute: jest.fn() },
|
||||
imageToVideo: { execute: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../actions/audio', () => ({
|
||||
textToSpeech: { execute: jest.fn() },
|
||||
}));
|
||||
|
||||
import { router } from '../actions/router';
|
||||
import * as text from '../actions/text';
|
||||
import * as image from '../actions/image';
|
||||
import * as video from '../actions/video';
|
||||
import * as audio from '../actions/audio';
|
||||
|
||||
describe('MiniMax Router', () => {
|
||||
let mockExecuteFunctions: ReturnType<typeof mock<IExecuteFunctions>>;
|
||||
|
||||
const mockNode = {
|
||||
id: 'test-node-id',
|
||||
name: 'Test Node',
|
||||
type: '@n8n/n8n-nodes-langchain.minimax',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should route text/message to text.message.execute', async () => {
|
||||
const expectedResult: INodeExecutionData = { json: { text: 'hello' }, pairedItem: 0 };
|
||||
(text.message.execute as jest.Mock).mockResolvedValue([expectedResult]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'resource') return 'text';
|
||||
if (param === 'operation') return 'message';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await router.call(mockExecuteFunctions);
|
||||
|
||||
expect(text.message.execute).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([[expectedResult]]);
|
||||
});
|
||||
|
||||
it('should route image/generate to image.generate.execute', async () => {
|
||||
const expectedResult: INodeExecutionData = {
|
||||
json: { imageUrl: 'https://example.com/img.png' },
|
||||
pairedItem: 0,
|
||||
};
|
||||
(image.generate.execute as jest.Mock).mockResolvedValue([expectedResult]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'resource') return 'image';
|
||||
if (param === 'operation') return 'generate';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await router.call(mockExecuteFunctions);
|
||||
|
||||
expect(image.generate.execute).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([[expectedResult]]);
|
||||
});
|
||||
|
||||
it('should route video/textToVideo to video.textToVideo.execute', async () => {
|
||||
const expectedResult: INodeExecutionData = {
|
||||
json: { videoUrl: 'https://example.com/video.mp4' },
|
||||
pairedItem: 0,
|
||||
};
|
||||
(video.textToVideo.execute as jest.Mock).mockResolvedValue([expectedResult]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'resource') return 'video';
|
||||
if (param === 'operation') return 'textToVideo';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await router.call(mockExecuteFunctions);
|
||||
|
||||
expect(video.textToVideo.execute).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([[expectedResult]]);
|
||||
});
|
||||
|
||||
it('should route video/imageToVideo to video.imageToVideo.execute', async () => {
|
||||
const expectedResult: INodeExecutionData = {
|
||||
json: { videoUrl: 'https://example.com/video.mp4' },
|
||||
pairedItem: 0,
|
||||
};
|
||||
(video.imageToVideo.execute as jest.Mock).mockResolvedValue([expectedResult]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'resource') return 'video';
|
||||
if (param === 'operation') return 'imageToVideo';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await router.call(mockExecuteFunctions);
|
||||
|
||||
expect(video.imageToVideo.execute).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([[expectedResult]]);
|
||||
});
|
||||
|
||||
it('should route audio/textToSpeech to audio.textToSpeech.execute', async () => {
|
||||
const expectedResult: INodeExecutionData = {
|
||||
json: { audioLength: 5 },
|
||||
pairedItem: 0,
|
||||
};
|
||||
(audio.textToSpeech.execute as jest.Mock).mockResolvedValue([expectedResult]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'resource') return 'audio';
|
||||
if (param === 'operation') return 'textToSpeech';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await router.call(mockExecuteFunctions);
|
||||
|
||||
expect(audio.textToSpeech.execute).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([[expectedResult]]);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError for unsupported resource', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'resource') return 'unsupported';
|
||||
if (param === 'operation') return 'test';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(router.call(mockExecuteFunctions)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should return error in json when continueOnFail is enabled and operation throws', async () => {
|
||||
(text.message.execute as jest.Mock).mockRejectedValue(new Error('API limit reached'));
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'resource') return 'text';
|
||||
if (param === 'operation') return 'message';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await router.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: { error: 'API limit reached' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest, pollVideoTask, getVideoDownloadUrl } from '../transport';
|
||||
|
||||
jest.mock('n8n-workflow', () => {
|
||||
const actual = jest.requireActual('n8n-workflow');
|
||||
return {
|
||||
...actual,
|
||||
sleep: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('MiniMax Transport', () => {
|
||||
let mockExecuteFunctions: ReturnType<typeof mockDeep<IExecuteFunctions>>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-key',
|
||||
url: 'https://api.minimax.io/v1',
|
||||
});
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'test-node-id',
|
||||
name: 'Test Node',
|
||||
type: '@n8n/n8n-nodes-langchain.minimax',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('should call httpRequestWithAuthentication with correct URL, method, and body', async () => {
|
||||
const mockResponse = { choices: [{ message: { content: 'hello' } }] };
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiRequest.call(mockExecuteFunctions, 'POST', '/chat/completions', {
|
||||
body: { model: 'MiniMax-M2.7', messages: [] },
|
||||
});
|
||||
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'minimaxApi',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
url: 'https://api.minimax.io/v1/chat/completions',
|
||||
body: { model: 'MiniMax-M2.7', messages: [] },
|
||||
json: true,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should pass through query string parameters', async () => {
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(mockExecuteFunctions, 'GET', '/query/video_generation', {
|
||||
qs: { task_id: 'task-123' },
|
||||
});
|
||||
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'minimaxApi',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
url: 'https://api.minimax.io/v1/query/video_generation',
|
||||
qs: { task_id: 'task-123' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve China region to correct base URL', async () => {
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-key',
|
||||
url: 'https://api.minimaxi.com/v1',
|
||||
});
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(mockExecuteFunctions, 'GET', '/files/retrieve');
|
||||
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'minimaxApi',
|
||||
expect.objectContaining({
|
||||
url: 'https://api.minimaxi.com/v1/files/retrieve',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pollVideoTask', () => {
|
||||
it('should return fileId when task status is Success', async () => {
|
||||
const succeededResponse = {
|
||||
status: 'Success',
|
||||
file_id: 'file-abc-123',
|
||||
};
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(
|
||||
succeededResponse,
|
||||
);
|
||||
|
||||
const result = await pollVideoTask.call(mockExecuteFunctions, 'task-123', 0);
|
||||
|
||||
expect(result).toEqual({ fileId: 'file-abc-123', status: 'Success' });
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when task status is Fail', async () => {
|
||||
const failedResponse = {
|
||||
status: 'Fail',
|
||||
base_resp: {
|
||||
status_code: 'CONTENT_MODERATION',
|
||||
status_msg: 'Content moderation failed',
|
||||
},
|
||||
};
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(failedResponse);
|
||||
|
||||
await expect(pollVideoTask.call(mockExecuteFunctions, 'task-456', 0)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
await expect(pollVideoTask.call(mockExecuteFunctions, 'task-456', 0)).rejects.toThrow(
|
||||
'Task failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw timeout error when max poll attempts exceeded', async () => {
|
||||
const pendingResponse = {
|
||||
status: 'Processing',
|
||||
};
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(pendingResponse);
|
||||
|
||||
await expect(pollVideoTask.call(mockExecuteFunctions, 'task-timeout', 0)).rejects.toThrow(
|
||||
/did not complete within the maximum polling time/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVideoDownloadUrl', () => {
|
||||
it('should return download URL from file retrieval response', async () => {
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue({
|
||||
file: {
|
||||
download_url: 'https://cdn.minimax.io/videos/abc.mp4',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getVideoDownloadUrl.call(mockExecuteFunctions, 'file-abc');
|
||||
|
||||
expect(result).toBe('https://cdn.minimax.io/videos/abc.mp4');
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when download URL is missing', async () => {
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue({
|
||||
file: {},
|
||||
});
|
||||
|
||||
await expect(getVideoDownloadUrl.call(mockExecuteFunctions, 'file-missing')).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
ILoadOptionsFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, sleep } from 'n8n-workflow';
|
||||
|
||||
type RequestParameters = {
|
||||
headers?: IDataObject;
|
||||
body?: IDataObject;
|
||||
qs?: IDataObject;
|
||||
option?: IDataObject;
|
||||
};
|
||||
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
parameters?: RequestParameters,
|
||||
) {
|
||||
const { body, qs, option, headers } = parameters ?? {};
|
||||
|
||||
const credentials = await this.getCredentials('minimaxApi');
|
||||
const baseUrl = (credentials.url as string) ?? 'https://api.minimax.io/v1';
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
const options = {
|
||||
headers: headers ?? {},
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
url,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (option && Object.keys(option).length !== 0) {
|
||||
Object.assign(options, option);
|
||||
}
|
||||
|
||||
return await this.helpers.httpRequestWithAuthentication.call(this, 'minimaxApi', options);
|
||||
}
|
||||
|
||||
const VIDEO_TERMINAL_STATUSES = ['Success', 'Fail'];
|
||||
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
||||
const MAX_POLL_ATTEMPTS = 60;
|
||||
|
||||
export async function pollVideoTask(
|
||||
this: IExecuteFunctions,
|
||||
taskId: string,
|
||||
pollIntervalMs: number = DEFAULT_POLL_INTERVAL_MS,
|
||||
): Promise<{ fileId: string; status: string }> {
|
||||
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
|
||||
const response = await apiRequest.call(this, 'GET', '/query/video_generation', {
|
||||
qs: { task_id: taskId },
|
||||
});
|
||||
|
||||
const status = response?.status as string;
|
||||
|
||||
if (VIDEO_TERMINAL_STATUSES.includes(status)) {
|
||||
if (status === 'Fail') {
|
||||
const errorCode = response?.base_resp?.status_code || 'UNKNOWN';
|
||||
const errorMessage = response?.base_resp?.status_msg || 'Video generation task failed';
|
||||
throw new NodeOperationError(this.getNode(), `Task failed: [${errorCode}] ${errorMessage}`);
|
||||
}
|
||||
|
||||
const fileId = response?.file_id as string;
|
||||
if (!fileId) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Video generation succeeded but no file_id was returned',
|
||||
);
|
||||
}
|
||||
|
||||
return { fileId, status };
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Video task ${taskId} did not complete within the maximum polling time. You can query the task manually using the task ID.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getVideoDownloadUrl(
|
||||
this: IExecuteFunctions,
|
||||
fileId: string,
|
||||
): Promise<string> {
|
||||
const response = await apiRequest.call(this, 'GET', '/files/retrieve', {
|
||||
qs: { file_id: fileId },
|
||||
});
|
||||
|
||||
const downloadUrl = response?.file?.download_url as string;
|
||||
if (!downloadUrl) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Failed to retrieve download URL for file ${fileId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return downloadUrl;
|
||||
}
|
||||
@@ -86,6 +86,7 @@
|
||||
"dist/nodes/vendors/AlibabaCloud/AlibabaCloud.node.js",
|
||||
"dist/nodes/vendors/Anthropic/Anthropic.node.js",
|
||||
"dist/nodes/vendors/GoogleGemini/GoogleGemini.node.js",
|
||||
"dist/nodes/vendors/MiniMax/MiniMax.node.js",
|
||||
"dist/nodes/vendors/Moonshot/Moonshot.node.js",
|
||||
"dist/nodes/vendors/Ollama/Ollama.node.js",
|
||||
"dist/nodes/vendors/OpenAi/OpenAi.node.js",
|
||||
|
||||
@@ -119,6 +119,7 @@ export const OLLAMA_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.ollama';
|
||||
export const GOOGLE_GEMINI_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.googleGemini';
|
||||
export const ALIBABA_CLOUD_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.alibabaCloud';
|
||||
export const MOONSHOT_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.moonshot';
|
||||
export const MINIMAX_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.minimax';
|
||||
|
||||
export const AI_VENDOR_NODE_TYPES = [
|
||||
OPENAI_LANGCHAIN_NODE_TYPE,
|
||||
@@ -127,6 +128,7 @@ export const AI_VENDOR_NODE_TYPES = [
|
||||
GOOGLE_GEMINI_LANGCHAIN_NODE_TYPE,
|
||||
ALIBABA_CLOUD_LANGCHAIN_NODE_TYPE,
|
||||
MOONSHOT_LANGCHAIN_NODE_TYPE,
|
||||
MINIMAX_LANGCHAIN_NODE_TYPE,
|
||||
];
|
||||
|
||||
export const LANGCHAIN_LM_NODE_TYPE_PREFIX = '@n8n/n8n-nodes-langchain.lm';
|
||||
|
||||
Reference in New Issue
Block a user