diff --git a/packages/components/credentials/MiniMaxApi.credential.ts b/packages/components/credentials/MiniMaxApi.credential.ts new file mode 100644 index 00000000000..7768e8ef932 --- /dev/null +++ b/packages/components/credentials/MiniMaxApi.credential.ts @@ -0,0 +1,39 @@ +import { INodeParams, INodeCredential } from '../src/Interface' + +class MiniMaxApi implements INodeCredential { + label: string + name: string + version: number + inputs: INodeParams[] + + constructor() { + this.label = 'MiniMax API' + this.name = 'miniMaxApi' + this.version = 1.0 + this.inputs = [ + { + label: 'MiniMax API Key', + name: 'miniMaxApiKey', + type: 'password' + }, + { + label: 'TTS API Region', + name: 'miniMaxTTSRegion', + type: 'options', + options: [ + { + label: 'Global', + name: 'global_en' + }, + { + label: 'China', + name: 'cn_zh' + } + ], + default: 'global_en' + } + ] + } +} + +module.exports = { credClass: MiniMaxApi } diff --git a/packages/components/models.json b/packages/components/models.json index 6b8ce72d5b8..5526cab7a74 100644 --- a/packages/components/models.json +++ b/packages/components/models.json @@ -2193,6 +2193,43 @@ } ] }, + { + "name": "chatMiniMax", + "models": [ + { + "label": "MiniMax-M3", + "name": "MiniMax-M3", + "description": "MiniMax M3 - 1,000,000-token context; text, image, and video input; adaptive or disabled thinking", + "context_window": 1000000, + "pricing_usd_per_million_tokens": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "cache_write": null + }, + "input_modalities": ["text", "image", "video"], + "thinking": ["adaptive", "disabled"], + "input_cost": 6e-7, + "output_cost": 2.4e-6 + }, + { + "label": "MiniMax-M2.7", + "name": "MiniMax-M2.7", + "description": "MiniMax M2.7 - 204,800-token context; text input; always-on thinking", + "context_window": 204800, + "pricing_usd_per_million_tokens": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": 0.375 + }, + "input_modalities": ["text"], + "thinking": ["always_on"], + "input_cost": 3e-7, + "output_cost": 1.2e-6 + } + ] + }, { "name": "chatMistralAI", "models": [ diff --git a/packages/components/nodes/chatmodels/ChatMiniMax/ChatMiniMax.ts b/packages/components/nodes/chatmodels/ChatMiniMax/ChatMiniMax.ts new file mode 100644 index 00000000000..7dda1e1de2c --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatMiniMax/ChatMiniMax.ts @@ -0,0 +1,118 @@ +import { ChatAnthropic as LangchainChatAnthropic } from '@langchain/anthropic' +import { BaseCache } from '@langchain/core/caches' +import { ICommonObject, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../src/Interface' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' +import { ChatMiniMax, ChatMiniMaxInput } from './FlowiseChatMiniMax' +import { getModels, MODEL_TYPE } from '../../../src/modelLoader' + +class ChatMiniMax_ChatModels implements INode { + label: string + name: string + version: number + type: string + icon: string + category: string + description: string + baseClasses: string[] + credential: INodeParams + inputs: INodeParams[] + + constructor() { + this.label = 'ChatMiniMax' + this.name = 'chatMiniMax' + this.version = 1.0 + this.type = 'ChatMiniMax' + this.icon = 'minimax.svg' + this.category = 'Chat Models' + this.description = 'Wrapper around MiniMax large language models using Anthropic-compatible API' + this.baseClasses = [this.type, ...getBaseClasses(LangchainChatAnthropic)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['miniMaxApi'] + } + this.inputs = [ + { + label: 'Cache', + name: 'cache', + type: 'BaseCache', + optional: true + }, + { + label: 'Model Name', + name: 'modelName', + type: 'asyncOptions', + loadMethod: 'listModels', + default: 'MiniMax-M3' + }, + { + label: 'Temperature', + name: 'temperature', + type: 'number', + step: 0.1, + default: 1.0, + optional: true + }, + { + label: 'Streaming', + name: 'streaming', + type: 'boolean', + default: true, + optional: true, + additionalParams: true + }, + { + label: 'Max Tokens', + name: 'maxTokens', + type: 'number', + step: 1, + optional: true, + additionalParams: true + }, + { + label: 'Top P', + name: 'topP', + type: 'number', + step: 0.1, + optional: true, + additionalParams: true + } + ] + } + + //@ts-ignore + loadMethods = { + async listModels(): Promise { + return await getModels(MODEL_TYPE.CHAT, 'chatMiniMax') + } + } + + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { + const temperature = nodeData.inputs?.temperature as string + const modelName = nodeData.inputs?.modelName as string + const maxTokens = nodeData.inputs?.maxTokens as string + const topP = nodeData.inputs?.topP as string + const streaming = nodeData.inputs?.streaming as boolean + const cache = nodeData.inputs?.cache as BaseCache + + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const miniMaxApiKey = getCredentialParam('miniMaxApiKey', credentialData, nodeData) + + const obj: ChatMiniMaxInput = { + modelName, + miniMaxApiKey, + streaming: streaming ?? true + } + + if (temperature) obj.temperature = parseFloat(temperature) + if (maxTokens) obj.maxTokens = parseInt(maxTokens, 10) + if (topP) obj.topP = parseFloat(topP) + if (cache) obj.cache = cache + + const model = new ChatMiniMax(nodeData.id, obj) + return model + } +} + +module.exports = { nodeClass: ChatMiniMax_ChatModels } diff --git a/packages/components/nodes/chatmodels/ChatMiniMax/FlowiseChatMiniMax.ts b/packages/components/nodes/chatmodels/ChatMiniMax/FlowiseChatMiniMax.ts new file mode 100644 index 00000000000..3388702ec86 --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatMiniMax/FlowiseChatMiniMax.ts @@ -0,0 +1,41 @@ +import { ChatAnthropic as LangchainChatAnthropic, AnthropicInput } from '@langchain/anthropic' +import { type BaseChatModelParams } from '@langchain/core/language_models/chat_models' + +export interface ChatMiniMaxInput extends Partial, BaseChatModelParams { + miniMaxApiKey?: string +} + +export class ChatMiniMax extends LangchainChatAnthropic { + configuredModel: string + configuredMaxToken: number + id: string + + constructor(id: string, fields?: ChatMiniMaxInput) { + const miniMaxApiKey = fields?.miniMaxApiKey || fields?.anthropicApiKey + + super({ + ...fields, + anthropicApiKey: miniMaxApiKey, + clientOptions: { + baseURL: 'https://api.minimax.io/anthropic' + } + }) + + this.id = id + this.configuredModel = fields?.modelName || 'MiniMax-M3' + this.configuredMaxToken = this.maxTokens + + // @langchain/anthropic defaults topP and topK to -1 as an "unset" sentinel and + // always serialises them into the request body. The real Anthropic API accepts + // -1 silently, but MiniMax's Anthropic-compatible endpoint requires top_p/top_k + // to be in (0, 1]. Setting them to undefined causes JSON.stringify to omit the + // fields entirely so MiniMax applies its own defaults. + if (fields?.topP === undefined) this.topP = undefined as unknown as number + if (fields?.topK === undefined) this.topK = undefined as unknown as number + } + + revertToOriginalModel(): void { + this.modelName = this.configuredModel + this.maxTokens = this.configuredMaxToken + } +} diff --git a/packages/components/nodes/chatmodels/ChatMiniMax/minimax.svg b/packages/components/nodes/chatmodels/ChatMiniMax/minimax.svg new file mode 100644 index 00000000000..2a60bd4731e --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatMiniMax/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/packages/components/src/minimaxParameters.test.ts b/packages/components/src/minimaxParameters.test.ts new file mode 100644 index 00000000000..3afc0efd093 --- /dev/null +++ b/packages/components/src/minimaxParameters.test.ts @@ -0,0 +1,33 @@ +import * as fs from 'fs' +import * as path from 'path' +import { getMiniMaxTTSEndpoint } from './textToSpeech' + +describe('MiniMax parameter refresh', () => { + const modelsPath = path.join(__dirname, '..', 'models.json') + const models = JSON.parse(fs.readFileSync(modelsPath, 'utf8')) + const catalog = models.chat.find((entry: any) => entry.name === 'chatMiniMax') + + it('records the current model parameters', () => { + expect(catalog.models).toEqual([ + expect.objectContaining({ + name: 'MiniMax-M3', + context_window: 1000000, + pricing_usd_per_million_tokens: { input: 0.6, output: 2.4, cache_read: 0.12, cache_write: null }, + input_modalities: ['text', 'image', 'video'], + thinking: ['adaptive', 'disabled'] + }), + expect.objectContaining({ + name: 'MiniMax-M2.7', + context_window: 204800, + pricing_usd_per_million_tokens: { input: 0.3, output: 1.2, cache_read: 0.06, cache_write: 0.375 }, + input_modalities: ['text'], + thinking: ['always_on'] + }) + ]) + }) + + it('resolves both TTS regions', () => { + expect(getMiniMaxTTSEndpoint()).toBe('https://api.minimax.io/v1/t2a_v2') + expect(getMiniMaxTTSEndpoint('cn_zh')).toBe('https://api.minimaxi.com/v1/t2a_v2') + }) +}) diff --git a/packages/components/src/textToSpeech.ts b/packages/components/src/textToSpeech.ts index c4611806406..9fc4804c5ad 100644 --- a/packages/components/src/textToSpeech.ts +++ b/packages/components/src/textToSpeech.ts @@ -7,7 +7,17 @@ import type { ReadableStream } from 'node:stream/web' const TextToSpeechType = { OPENAI_TTS: 'openai', - ELEVEN_LABS_TTS: 'elevenlabs' + ELEVEN_LABS_TTS: 'elevenlabs', + MINIMAX_TTS: 'minimax' +} + +const MINIMAX_TTS_ENDPOINTS = { + global_en: 'https://api.minimax.io/v1/t2a_v2', + cn_zh: 'https://api.minimaxi.com/v1/t2a_v2' +} as const + +export const getMiniMaxTTSEndpoint = (region?: string): string => { + return MINIMAX_TTS_ENDPOINTS[region === 'cn_zh' ? 'cn_zh' : 'global_en'] } export const convertTextToSpeechStream = async ( @@ -100,6 +110,118 @@ export const convertTextToSpeechStream = async ( }) break } + + case TextToSpeechType.MINIMAX_TTS: { + onStart('mp3') + + const apiKey = credentialData.miniMaxApiKey + if (!apiKey) { + throw new Error('MiniMax API Key is required') + } + + const voiceId = textToSpeechConfig.voice || 'English_expressive_narrator' + const model = textToSpeechConfig.model || 'speech-2.8-hd' + + const requestBody: Record = { + model: model, + text: text, + stream: true, + language_boost: 'auto', + output_format: 'hex', + voice_setting: { + voice_id: voiceId, + speed: textToSpeechConfig.speed ?? 1.0, + vol: textToSpeechConfig.vol ?? 1.0, + pitch: textToSpeechConfig.pitch ?? 0 + }, + audio_setting: { + format: 'mp3', + sample_rate: 32000, + bitrate: 128000, + channel: 1 + } + } + + const response = await fetch(getMiniMaxTTSEndpoint(credentialData.miniMaxTTSRegion as string | undefined), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify(requestBody), + signal: abortController.signal + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`MiniMax TTS API error: ${response.status} - ${errorText}`) + } + + if (!response.body) { + throw new Error('Failed to get response stream from MiniMax') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let sseBuffer = '' + + const processMinimaxStream = async () => { + for (;;) { + if (abortController.signal.aborted) { + reader.cancel() + streamDestroyed = true + reject(new Error('TTS generation aborted')) + return + } + + const { done, value } = await reader.read() + if (done) break + + sseBuffer += decoder.decode(value, { stream: true }) + const lines = sseBuffer.split('\n') + sseBuffer = lines.pop() || '' + + for (const line of lines) { + const trimmedLine = line.trim() + if (!trimmedLine || trimmedLine.startsWith(':')) { + continue + } + + if (trimmedLine.startsWith('data:')) { + const jsonStr = trimmedLine.slice(5).trim() + if (!jsonStr) continue + + try { + const eventData = JSON.parse(jsonStr) + + if (eventData.base_resp?.status_code !== 0) { + const errorMsg = eventData.base_resp?.status_msg || 'Unknown error' + reject(new Error(`MiniMax TTS error: ${errorMsg}`)) + return + } + + if (eventData.data?.audio) { + const audioChunk = Buffer.from(eventData.data.audio, 'hex') + onChunk(audioChunk) + } + + if (eventData.data?.status === 2) { + break + } + } catch { + // Skip malformed JSON + } + } + } + } + + onEnd() + resolve() + } + + await processMinimaxStream() + break + } } } else { reject(new Error('Text to speech is not selected. Please configure TTS in the chatflow.')) @@ -234,6 +356,18 @@ export const getVoices = async (provider: string, credentialId: string, options: })) } + case TextToSpeechType.MINIMAX_TTS: { + return [ + // English voices (official recommended) + { id: 'English_expressive_narrator', name: 'Expressive Narrator', category: 'English' }, + { id: 'English_Graceful_Lady', name: 'Graceful Lady', category: 'English' }, + { id: 'English_Insightful_Speaker', name: 'Insightful Speaker', category: 'English' }, + { id: 'English_radiant_girl', name: 'Radiant Girl', category: 'English' }, + { id: 'English_Persuasive_Man', name: 'Persuasive Man', category: 'English' }, + { id: 'English_Lucky_Robot', name: 'Lucky Robot', category: 'English' } + ] + } + default: throw new Error(`Unsupported TTS provider: ${provider}`) }