952 lines
29 KiB
TypeScript
952 lines
29 KiB
TypeScript
import type {
|
|
IBinaryData,
|
|
IDataObject,
|
|
IExecuteFunctions,
|
|
IHttpRequestOptions,
|
|
INodeExecutionData,
|
|
INodeType,
|
|
INodeTypeDescription,
|
|
} from 'n8n-workflow';
|
|
import { NodeOperationError } from 'n8n-workflow';
|
|
|
|
import { modelLoader } from '../shared/loadModels';
|
|
import {
|
|
errorMessage,
|
|
hintForError,
|
|
normalizeBaseUrl,
|
|
parseExtraBody,
|
|
type TeamorouterModel,
|
|
} from '../shared/teamorouter';
|
|
|
|
interface ChatContentPart extends IDataObject {
|
|
type?: string;
|
|
text?: string;
|
|
image_url?: { url?: string };
|
|
}
|
|
|
|
interface ChatCompletionResponse extends IDataObject {
|
|
model?: string;
|
|
choices?: Array<{
|
|
finish_reason?: string;
|
|
message?: {
|
|
content?: string | null | ChatContentPart[];
|
|
images?: Array<{ image_url?: { url?: string }; url?: string }>;
|
|
};
|
|
}>;
|
|
usage?: IDataObject;
|
|
}
|
|
|
|
interface ImageResponse extends IDataObject {
|
|
data?: Array<{ b64_json?: string; url?: string }>;
|
|
}
|
|
|
|
interface ChatOptions {
|
|
systemPrompt?: string;
|
|
temperature?: number;
|
|
maxTokens?: number;
|
|
topP?: number;
|
|
forceJsonResponse?: boolean;
|
|
serviceTier?: 'auto' | 'fast';
|
|
extraBody?: unknown;
|
|
timeout?: number;
|
|
}
|
|
|
|
interface ImageOptions {
|
|
size?: string;
|
|
quality?: string;
|
|
numberOfImages?: number;
|
|
outputFormat?: string;
|
|
outputCompression?: number;
|
|
background?: string;
|
|
moderation?: string;
|
|
inputFidelity?: string;
|
|
timeout?: number;
|
|
}
|
|
|
|
interface InputImage {
|
|
source?: string;
|
|
binaryProperty?: string;
|
|
url?: string;
|
|
}
|
|
|
|
interface BinaryPart {
|
|
buffer: Buffer;
|
|
fileName: string;
|
|
mimeType: string;
|
|
}
|
|
|
|
interface MultipartPart {
|
|
name: string;
|
|
value?: string;
|
|
buffer?: Buffer;
|
|
fileName?: string;
|
|
mimeType?: string;
|
|
}
|
|
|
|
const MIME_BY_FORMAT: Record<string, string> = {
|
|
png: 'image/png',
|
|
jpeg: 'image/jpeg',
|
|
webp: 'image/webp',
|
|
};
|
|
|
|
const EXTENSION_BY_FORMAT: Record<string, string> = { png: 'png', jpeg: 'jpeg', webp: 'webp' };
|
|
|
|
/** Reads the binary property behind an input item and turns it into a data URL. */
|
|
async function binaryDataUrl(
|
|
context: IExecuteFunctions,
|
|
itemIndex: number,
|
|
propertyName: string,
|
|
): Promise<string> {
|
|
const binary = await readBinary(context, itemIndex, propertyName);
|
|
return `data:${binary.mimeType};base64,${binary.buffer.toString('base64')}`;
|
|
}
|
|
|
|
async function readBinary(
|
|
context: IExecuteFunctions,
|
|
itemIndex: number,
|
|
propertyName: string,
|
|
): Promise<BinaryPart> {
|
|
const metadata = context.getInputData()[itemIndex]?.binary?.[propertyName];
|
|
if (metadata === undefined) {
|
|
throw new NodeOperationError(
|
|
context.getNode(),
|
|
`No binary data on the input item field "${propertyName}"`,
|
|
{ itemIndex },
|
|
);
|
|
}
|
|
return {
|
|
buffer: await context.helpers.getBinaryDataBuffer(itemIndex, propertyName),
|
|
fileName: metadata.fileName ?? `${propertyName}.png`,
|
|
mimeType: metadata.mimeType ?? 'image/png',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hand-rolled multipart body: n8n's httpRequest wants the `form-data` package, which a community
|
|
* node cannot rely on, and /v1/images/edits is documented with `-F image=@file`.
|
|
*/
|
|
function buildMultipart(parts: MultipartPart[]): { body: Buffer; contentType: string } {
|
|
const boundary = `----n8nTeamorouter${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`;
|
|
const chunks: Buffer[] = [];
|
|
for (const part of parts) {
|
|
let header = `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"`;
|
|
if (part.fileName !== undefined) header += `; filename="${part.fileName}"`;
|
|
header += '\r\n';
|
|
if (part.mimeType !== undefined) header += `Content-Type: ${part.mimeType}\r\n`;
|
|
chunks.push(
|
|
Buffer.from(`${header}\r\n`, 'utf8'),
|
|
part.buffer ?? Buffer.from(part.value ?? '', 'utf8'),
|
|
Buffer.from('\r\n', 'utf8'),
|
|
);
|
|
}
|
|
chunks.push(Buffer.from(`--${boundary}--\r\n`, 'utf8'));
|
|
return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` };
|
|
}
|
|
|
|
function splitDataUrl(url: string): { mimeType: string; base64: string } | undefined {
|
|
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url);
|
|
return match === null ? undefined : { mimeType: match[1], base64: match[2] };
|
|
}
|
|
|
|
/** Adds TeamoRouter specific hints to failed requests and keeps the original error otherwise. */
|
|
async function callApi<T>(context: IExecuteFunctions, requestOptions: IHttpRequestOptions): Promise<T> {
|
|
try {
|
|
return (await context.helpers.httpRequestWithAuthentication.call(
|
|
context,
|
|
'teamorouterApi',
|
|
requestOptions,
|
|
)) as T;
|
|
} catch (error) {
|
|
const hint = hintForError(error);
|
|
if (hint === undefined) throw error;
|
|
throw new NodeOperationError(context.getNode(), `${hint} ${errorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
/** Text plus optional images, in the shape every OpenAI-compatible vision model accepts. */
|
|
async function buildUserContent(
|
|
context: IExecuteFunctions,
|
|
itemIndex: number,
|
|
prompt: string,
|
|
images: InputImage[],
|
|
): Promise<string | IDataObject[]> {
|
|
if (images.length === 0) return prompt;
|
|
|
|
const content: IDataObject[] = [];
|
|
if (prompt !== '') content.push({ type: 'text', text: prompt });
|
|
for (const image of images) {
|
|
const url =
|
|
image.source === 'url'
|
|
? (image.url ?? '').trim()
|
|
: await binaryDataUrl(context, itemIndex, image.binaryProperty ?? 'data');
|
|
if (url === '') {
|
|
throw new NodeOperationError(context.getNode(), 'An input image has no URL', { itemIndex });
|
|
}
|
|
content.push({ type: 'image_url', image_url: { url } });
|
|
}
|
|
return content;
|
|
}
|
|
|
|
function buildChatBody(
|
|
context: IExecuteFunctions,
|
|
model: string,
|
|
content: string | IDataObject[],
|
|
options: ChatOptions,
|
|
): IDataObject {
|
|
const messages: IDataObject[] = [];
|
|
if (typeof options.systemPrompt === 'string' && options.systemPrompt !== '') {
|
|
messages.push({ role: 'system', content: options.systemPrompt });
|
|
}
|
|
messages.push({ role: 'user', content });
|
|
|
|
const body: IDataObject = { model, messages };
|
|
if (typeof options.temperature === 'number') body.temperature = options.temperature;
|
|
if (typeof options.topP === 'number') body.top_p = options.topP;
|
|
if (typeof options.maxTokens === 'number' && options.maxTokens > 0) body.max_tokens = options.maxTokens;
|
|
if (options.forceJsonResponse === true) body.response_format = { type: 'json_object' };
|
|
if (options.serviceTier === 'fast') body.service_tier = 'fast';
|
|
|
|
return Object.assign(body, parseExtraBody(options.extraBody, context.getNode()));
|
|
}
|
|
|
|
/** The image parameters shared by /images/generations and /images/edits. */
|
|
function buildImageBody(model: string, prompt: string, options: ImageOptions): IDataObject {
|
|
const body: IDataObject = { model, prompt };
|
|
if (options.size !== undefined && options.size !== '') body.size = options.size;
|
|
if (options.quality !== undefined && options.quality !== '') body.quality = options.quality;
|
|
if (typeof options.numberOfImages === 'number' && options.numberOfImages > 1) {
|
|
body.n = options.numberOfImages;
|
|
}
|
|
if (options.outputFormat !== undefined && options.outputFormat !== '') {
|
|
body.output_format = options.outputFormat;
|
|
}
|
|
if (typeof options.outputCompression === 'number' && options.outputCompression >= 0) {
|
|
body.output_compression = options.outputCompression;
|
|
}
|
|
if (options.background !== undefined && options.background !== '') body.background = options.background;
|
|
if (options.moderation !== undefined && options.moderation !== '') body.moderation = options.moderation;
|
|
if (options.inputFidelity !== undefined && options.inputFidelity !== '') {
|
|
body.input_fidelity = options.inputFidelity;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function extractChatText(response: ChatCompletionResponse): string {
|
|
const content = response.choices?.[0]?.message?.content;
|
|
if (typeof content === 'string') return content;
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.filter((part) => typeof part?.text === 'string')
|
|
.map((part) => part.text ?? '')
|
|
.join('');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/** Image URLs a multimodal chat model may answer with, in either common response shape. */
|
|
function extractChatImages(response: ChatCompletionResponse): string[] {
|
|
const message = response.choices?.[0]?.message;
|
|
const urls: string[] = [];
|
|
for (const image of message?.images ?? []) {
|
|
const url = image?.image_url?.url ?? image?.url;
|
|
if (typeof url === 'string' && url !== '') urls.push(url);
|
|
}
|
|
if (Array.isArray(message?.content)) {
|
|
for (const part of message.content) {
|
|
const url = part?.image_url?.url;
|
|
if (typeof url === 'string' && url !== '') urls.push(url);
|
|
}
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
export class Teamorouter implements INodeType {
|
|
description: INodeTypeDescription = {
|
|
displayName: 'TeamoRouter',
|
|
codex: {
|
|
categories: ['AI'],
|
|
subcategories: {
|
|
AI: ['Root Nodes'],
|
|
},
|
|
},
|
|
name: 'teamorouter',
|
|
icon: 'file:teamorouter.svg',
|
|
group: ['transform'],
|
|
version: 1,
|
|
subtitle: '={{$parameter["operation"] + " " + $parameter["resource"]}}',
|
|
description:
|
|
'Call the TeamoRouter gateway: chat completions with text and images, image generation and editing, model list (GPT, Claude, Gemini, DeepSeek, GLM, Grok, Kimi)',
|
|
|
|
defaults: {
|
|
name: 'TeamoRouter',
|
|
},
|
|
|
|
usableAsTool: true,
|
|
|
|
inputs: ['main'],
|
|
outputs: ['main'],
|
|
|
|
credentials: [
|
|
{
|
|
name: 'teamorouterApi',
|
|
required: true,
|
|
},
|
|
],
|
|
|
|
properties: [
|
|
{
|
|
displayName: 'Resource',
|
|
name: 'resource',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
options: [
|
|
{
|
|
name: 'Chat',
|
|
value: 'chat',
|
|
description: 'Send text and images to a chat model and get its answer',
|
|
},
|
|
{
|
|
name: 'Image',
|
|
value: 'image',
|
|
description: 'Generate an image from a prompt, or edit an existing image',
|
|
},
|
|
{
|
|
name: 'Model',
|
|
value: 'model',
|
|
description: 'Read the model list from the router',
|
|
},
|
|
],
|
|
default: 'chat',
|
|
},
|
|
|
|
// ---------------------------------------------------------------- chat
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['chat'] } },
|
|
options: [
|
|
{
|
|
name: 'Message a Model',
|
|
value: 'message',
|
|
description: 'Send a prompt and get the completion',
|
|
action: 'Send a chat completion request',
|
|
},
|
|
],
|
|
default: 'message',
|
|
},
|
|
{
|
|
displayName: 'Model',
|
|
name: 'model',
|
|
type: 'options',
|
|
typeOptions: {
|
|
loadOptionsMethod: 'getChatModels',
|
|
},
|
|
default: '',
|
|
required: true,
|
|
displayOptions: { show: { resource: ['chat'] } },
|
|
description:
|
|
'Model ID to use. The list is read live from GET /v1/models. For an ID that is missing from the list, switch this field to expression mode and enter the ID.',
|
|
},
|
|
{
|
|
displayName: 'Prompt',
|
|
name: 'prompt',
|
|
type: 'string',
|
|
default: '',
|
|
typeOptions: { rows: 4 },
|
|
displayOptions: { show: { resource: ['chat'] } },
|
|
description: 'Message to send to the model. Optional when input images are attached.',
|
|
},
|
|
{
|
|
displayName: 'Input Images',
|
|
name: 'images',
|
|
type: 'fixedCollection',
|
|
typeOptions: { multipleValues: true },
|
|
placeholder: 'Add Image',
|
|
default: {},
|
|
displayOptions: { show: { resource: ['chat'] } },
|
|
description:
|
|
'Images to send along with the prompt. Needs a vision model, e.g. GPT, Claude or Gemini.',
|
|
options: [
|
|
{
|
|
name: 'image',
|
|
displayName: 'Image',
|
|
values: [
|
|
{
|
|
displayName: 'Source',
|
|
name: 'source',
|
|
type: 'options',
|
|
default: 'binary',
|
|
options: [
|
|
{
|
|
name: 'Binary File',
|
|
value: 'binary',
|
|
description: 'An image coming from an earlier node in the workflow',
|
|
},
|
|
{
|
|
name: 'Image URL',
|
|
value: 'url',
|
|
description: 'A link to the image, or a data:image/... URL',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
displayName: 'Binary Property',
|
|
name: 'binaryProperty',
|
|
type: 'string',
|
|
default: 'data',
|
|
description:
|
|
'Name of the binary property that holds the image. Used when Source is Binary File.',
|
|
},
|
|
{
|
|
displayName: 'Image URL',
|
|
name: 'url',
|
|
type: 'string',
|
|
default: '',
|
|
placeholder: 'https://example.com/cat.png',
|
|
description:
|
|
'Publicly reachable image link, or a data:image/...;base64,... URL. Used when Source is Image URL.',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
displayName: 'Options',
|
|
name: 'options',
|
|
placeholder: 'Add Option',
|
|
type: 'collection',
|
|
default: {},
|
|
displayOptions: { show: { resource: ['chat'] } },
|
|
options: [
|
|
{
|
|
displayName: 'Extra Body',
|
|
name: 'extraBody',
|
|
type: 'json',
|
|
default: '',
|
|
description:
|
|
'Extra JSON properties to merge into the request body, e.g. {"top_k": 40}. Overrides the fields set by the options above.',
|
|
},
|
|
{
|
|
displayName: 'Force JSON Response',
|
|
name: 'forceJsonResponse',
|
|
type: 'boolean',
|
|
default: false,
|
|
description:
|
|
'Whether to request a JSON object from the model. The prompt must mention JSON.',
|
|
},
|
|
{
|
|
displayName: 'Maximum Number of Tokens',
|
|
name: 'maxTokens',
|
|
type: 'number',
|
|
default: -1,
|
|
description: 'Maximum number of tokens to generate. -1 leaves it to the model.',
|
|
},
|
|
{
|
|
displayName: 'Sampling Temperature',
|
|
name: 'temperature',
|
|
type: 'number',
|
|
default: 0.7,
|
|
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
|
|
description:
|
|
'Controls randomness: lower values make the output more deterministic and repetitive',
|
|
},
|
|
{
|
|
displayName: 'Service Tier',
|
|
name: 'serviceTier',
|
|
type: 'options',
|
|
default: 'auto',
|
|
options: [
|
|
{ name: 'Auto', value: 'auto', description: 'Standard processing' },
|
|
{
|
|
name: 'Fast',
|
|
value: 'fast',
|
|
description: 'TeamoRouter Fast mode: up to 2.5x faster, billed at 2x the standard rate',
|
|
},
|
|
],
|
|
description: 'Processing mode requested from TeamoRouter',
|
|
},
|
|
{
|
|
displayName: 'System Prompt',
|
|
name: 'systemPrompt',
|
|
type: 'string',
|
|
default: '',
|
|
typeOptions: { rows: 3 },
|
|
description: 'Optional instruction that sets the behaviour of the assistant',
|
|
},
|
|
{
|
|
displayName: 'Timeout',
|
|
name: 'timeout',
|
|
type: 'number',
|
|
default: 600000,
|
|
description:
|
|
'Time to wait for an answer in milliseconds. TeamoRouter allows responses up to 600 seconds.',
|
|
},
|
|
{
|
|
displayName: 'Top P',
|
|
name: 'topP',
|
|
type: 'number',
|
|
default: 1,
|
|
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 2 },
|
|
description:
|
|
'Controls diversity via nucleus sampling. Change this or the temperature, not both.',
|
|
},
|
|
],
|
|
},
|
|
|
|
// --------------------------------------------------------------- image
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['image'] } },
|
|
options: [
|
|
{
|
|
name: 'Generate an Image',
|
|
value: 'generate',
|
|
description: 'Create an image from a text prompt',
|
|
action: 'Generate an image',
|
|
},
|
|
{
|
|
name: 'Edit an Image',
|
|
value: 'edit',
|
|
description: 'Change an existing image with a text instruction',
|
|
action: 'Edit an image',
|
|
},
|
|
],
|
|
default: 'generate',
|
|
},
|
|
{
|
|
displayName: 'Model',
|
|
name: 'imageModel',
|
|
type: 'options',
|
|
typeOptions: {
|
|
loadOptionsMethod: 'getImageModels',
|
|
},
|
|
default: 'gpt-image-2',
|
|
required: true,
|
|
displayOptions: { show: { resource: ['image'] } },
|
|
description:
|
|
'Image model ID. The list is read live from GET /v1/models. For an ID that is missing from the list, switch this field to expression mode and enter the ID.',
|
|
},
|
|
{
|
|
displayName: 'Prompt',
|
|
name: 'imagePrompt',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
typeOptions: { rows: 4 },
|
|
displayOptions: { show: { resource: ['image'] } },
|
|
description: 'Description of the image to generate, or of the change to apply when editing',
|
|
},
|
|
{
|
|
displayName: 'Input Image (Binary Property)',
|
|
name: 'inputBinaryProperty',
|
|
type: 'string',
|
|
default: 'data',
|
|
required: true,
|
|
displayOptions: { show: { resource: ['image'], operation: ['edit'] } },
|
|
description: 'Name of the binary property that holds the image to edit',
|
|
},
|
|
{
|
|
displayName: 'Mask (Binary Property)',
|
|
name: 'maskBinaryProperty',
|
|
type: 'string',
|
|
default: '',
|
|
displayOptions: { show: { resource: ['image'], operation: ['edit'] } },
|
|
description:
|
|
'Optional binary property with a mask: transparent pixels mark the area to edit',
|
|
},
|
|
{
|
|
displayName: 'Put Output File in Field',
|
|
name: 'binaryPropertyName',
|
|
type: 'string',
|
|
default: 'data',
|
|
required: true,
|
|
displayOptions: { show: { resource: ['image'] } },
|
|
description:
|
|
'Name of the binary property that holds the generated image. With several images, the next ones are suffixed with a number.',
|
|
},
|
|
{
|
|
displayName: 'Options',
|
|
name: 'options',
|
|
placeholder: 'Add Option',
|
|
type: 'collection',
|
|
default: {},
|
|
displayOptions: { show: { resource: ['image'] } },
|
|
options: [
|
|
{
|
|
displayName: 'Background',
|
|
name: 'background',
|
|
type: 'options',
|
|
default: 'auto',
|
|
options: [
|
|
{ name: 'Auto', value: 'auto', description: 'Let the model decide' },
|
|
{
|
|
name: 'Opaque',
|
|
value: 'opaque',
|
|
description: 'Solid background, supported by every output format',
|
|
},
|
|
{
|
|
name: 'Transparent',
|
|
value: 'transparent',
|
|
description: 'Needs the PNG or WebP output format',
|
|
},
|
|
],
|
|
description: 'Transparency of the generated image',
|
|
},
|
|
{
|
|
displayName: 'Input Fidelity',
|
|
name: 'inputFidelity',
|
|
type: 'options',
|
|
default: 'high',
|
|
options: [
|
|
{
|
|
name: 'High',
|
|
value: 'high',
|
|
description: 'Keep the details of the source image, e.g. faces',
|
|
},
|
|
{
|
|
name: 'Low',
|
|
value: 'low',
|
|
description: 'Allow the model to reinterpret the source image more freely',
|
|
},
|
|
],
|
|
description: 'How closely the edit follows the source image. Only used when editing an image.',
|
|
},
|
|
{
|
|
displayName: 'Moderation',
|
|
name: 'moderation',
|
|
type: 'options',
|
|
default: 'auto',
|
|
options: [
|
|
{ name: 'Auto', value: 'auto', description: 'Standard content filter' },
|
|
{ name: 'Low', value: 'low', description: 'Relaxed content filter' },
|
|
],
|
|
description: 'Content filter applied by TeamoRouter. Only used when generating an image.',
|
|
},
|
|
{
|
|
displayName: 'Number of Images',
|
|
name: 'numberOfImages',
|
|
type: 'number',
|
|
default: 1,
|
|
typeOptions: { minValue: 1 },
|
|
description: 'How many images to generate from this prompt',
|
|
},
|
|
{
|
|
displayName: 'Output Compression',
|
|
name: 'outputCompression',
|
|
type: 'number',
|
|
default: -1,
|
|
typeOptions: { minValue: -1, maxValue: 100 },
|
|
description:
|
|
'Compression level 0-100 for JPEG and WebP. -1 leaves the default.',
|
|
},
|
|
{
|
|
displayName: 'Output Format',
|
|
name: 'outputFormat',
|
|
type: 'options',
|
|
default: 'png',
|
|
options: [
|
|
{ name: 'PNG', value: 'png', description: 'Lossless, supports transparency' },
|
|
{ name: 'JPEG', value: 'jpeg', description: 'Smaller files, no transparency' },
|
|
{ name: 'WebP', value: 'webp', description: 'Small files, supports transparency' },
|
|
],
|
|
description: 'File format the router returns',
|
|
},
|
|
{
|
|
displayName: 'Quality',
|
|
name: 'quality',
|
|
type: 'options',
|
|
default: 'auto',
|
|
options: [
|
|
{ name: 'Auto', value: 'auto' },
|
|
{ name: 'Low', value: 'low', description: 'Fastest and cheapest' },
|
|
{ name: 'Medium', value: 'medium' },
|
|
{ name: 'High', value: 'high' },
|
|
],
|
|
description: 'Rendering effort spent on the image',
|
|
},
|
|
{
|
|
displayName: 'Size',
|
|
name: 'size',
|
|
type: 'options',
|
|
default: '1024x1024',
|
|
options: [
|
|
{ name: 'Auto', value: 'auto', description: 'Let the model pick a size' },
|
|
{ name: '1024x1024 (Square)', value: '1024x1024' },
|
|
{ name: '1536x1024 (Landscape)', value: '1536x1024' },
|
|
{ name: '1024x1536 (Portrait)', value: '1024x1536' },
|
|
{ name: '2048x2048', value: '2048x2048' },
|
|
],
|
|
description:
|
|
'Image size. Other values are allowed through an expression: both sides must be multiples of 16, the longest side at most 3840 and the aspect ratio at most 3:1.',
|
|
},
|
|
{
|
|
displayName: 'Timeout',
|
|
name: 'timeout',
|
|
type: 'number',
|
|
default: 300000,
|
|
description:
|
|
'Time to wait for the image in milliseconds. Generation usually takes 20-40 seconds.',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
displayName:
|
|
'Image generation can take a while. The request waits up to 5 minutes before it fails.',
|
|
name: 'imageNotice',
|
|
type: 'notice',
|
|
default: '',
|
|
displayOptions: { show: { resource: ['image'] } },
|
|
},
|
|
|
|
// --------------------------------------------------------------- model
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['model'] } },
|
|
options: [
|
|
{
|
|
name: 'List Models',
|
|
value: 'list',
|
|
description: 'Read the models currently offered by TeamoRouter',
|
|
action: 'List models',
|
|
},
|
|
],
|
|
default: 'list',
|
|
},
|
|
|
|
// -------------------------------------------------------------- output
|
|
{
|
|
displayName: 'Chat Reply',
|
|
name: 'chatReply',
|
|
type: 'boolean',
|
|
default: false,
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['chat', 'image'] } },
|
|
description:
|
|
'Whether to put the answer into an <code>output</code> field that a Chat Trigger sends back to the chat: the model text, or the generated image as markdown.',
|
|
},
|
|
],
|
|
};
|
|
|
|
methods = {
|
|
loadOptions: {
|
|
getChatModels: modelLoader('chat'),
|
|
getImageModels: modelLoader('image'),
|
|
},
|
|
};
|
|
|
|
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
|
const items = this.getInputData();
|
|
const returnData: INodeExecutionData[] = [];
|
|
const credentials = await this.getCredentials('teamorouterApi');
|
|
const baseURL = normalizeBaseUrl(credentials.baseUrl as string);
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
try {
|
|
const resource = this.getNodeParameter('resource', i) as string;
|
|
const chatReply = this.getNodeParameter('chatReply', i, false) as boolean;
|
|
|
|
if (resource === 'chat') {
|
|
const model = this.getNodeParameter('model', i) as string;
|
|
const prompt = (this.getNodeParameter('prompt', i, '') as string) ?? '';
|
|
const images = (this.getNodeParameter('images', i, {}) as { image?: InputImage[] })
|
|
.image ?? [];
|
|
const options = this.getNodeParameter('options', i, {}) as ChatOptions;
|
|
|
|
if (prompt.trim() === '' && images.length === 0) {
|
|
throw new NodeOperationError(
|
|
this.getNode(),
|
|
'Enter a Prompt or attach at least one input image',
|
|
{ itemIndex: i },
|
|
);
|
|
}
|
|
|
|
const response = await callApi<ChatCompletionResponse>(this, {
|
|
method: 'POST',
|
|
baseURL,
|
|
url: '/chat/completions',
|
|
body: buildChatBody(
|
|
this,
|
|
model,
|
|
await buildUserContent(this, i, prompt, images),
|
|
options,
|
|
),
|
|
json: true,
|
|
timeout:
|
|
typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : 600000,
|
|
});
|
|
|
|
const text = extractChatText(response);
|
|
const imageUrls = extractChatImages(response);
|
|
const json: IDataObject = { text, model: response.model ?? model };
|
|
if (response.usage !== undefined) json.usage = response.usage;
|
|
const finishReason = response.choices?.[0]?.finish_reason;
|
|
if (typeof finishReason === 'string') json.finishReason = finishReason;
|
|
|
|
const binary: Record<string, IBinaryData> = {};
|
|
const markdown: string[] = [];
|
|
for (const [index, url] of imageUrls.entries()) {
|
|
const inline = splitDataUrl(url);
|
|
if (inline === undefined) {
|
|
markdown.push(``);
|
|
continue;
|
|
}
|
|
const propertyName = index === 0 ? 'data' : `data${index}`;
|
|
binary[propertyName] = await this.helpers.prepareBinaryData(
|
|
Buffer.from(inline.base64, 'base64'),
|
|
`teamo-image-${Date.now()}-${index}.${EXTENSION_BY_FORMAT[inline.mimeType.split('/')[1]] ?? 'png'}`,
|
|
inline.mimeType,
|
|
);
|
|
markdown.push(``);
|
|
}
|
|
if (imageUrls.length > 0) json.images = imageUrls;
|
|
|
|
const reply = [text, ...markdown].filter((part) => part !== '').join('\n\n');
|
|
if (chatReply) json.output = reply;
|
|
|
|
returnData.push({
|
|
json,
|
|
...(Object.keys(binary).length > 0 ? { binary } : {}),
|
|
pairedItem: { item: i },
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (resource === 'image') {
|
|
const operation = this.getNodeParameter('operation', i) as string;
|
|
const imageModel = this.getNodeParameter('imageModel', i) as string;
|
|
const prompt = this.getNodeParameter('imagePrompt', i) as string;
|
|
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string;
|
|
const options = this.getNodeParameter('options', i, {}) as ImageOptions;
|
|
const timeout =
|
|
typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : 300000;
|
|
const requestBody: IHttpRequestOptions = {
|
|
method: 'POST',
|
|
baseURL,
|
|
url: '',
|
|
json: true,
|
|
timeout,
|
|
};
|
|
|
|
if (operation === 'edit') {
|
|
// The edits endpoint wants the picture itself, so it always goes as multipart.
|
|
const parts: MultipartPart[] = [];
|
|
for (const [name, value] of Object.entries(buildImageBody(imageModel, prompt, options))) {
|
|
parts.push({ name, value: String(value) });
|
|
}
|
|
const input = await readBinary(
|
|
this,
|
|
i,
|
|
this.getNodeParameter('inputBinaryProperty', i, 'data') as string,
|
|
);
|
|
parts.push({ name: 'image', ...input });
|
|
const maskProperty = this.getNodeParameter('maskBinaryProperty', i, '') as string;
|
|
if (maskProperty !== '') {
|
|
parts.push({ name: 'mask', ...(await readBinary(this, i, maskProperty)) });
|
|
}
|
|
const multipart = buildMultipart(parts);
|
|
requestBody.url = '/images/edits';
|
|
requestBody.body = multipart.body;
|
|
requestBody.headers = { 'content-type': multipart.contentType };
|
|
} else {
|
|
requestBody.url = '/images/generations';
|
|
requestBody.body = buildImageBody(imageModel, prompt, options);
|
|
}
|
|
|
|
const response = await callApi<ImageResponse>(this, requestBody);
|
|
const results = (response.data ?? []).filter(
|
|
(entry) =>
|
|
(typeof entry?.b64_json === 'string' && entry.b64_json !== '') ||
|
|
(typeof entry?.url === 'string' && entry.url !== ''),
|
|
);
|
|
if (results.length === 0) {
|
|
throw new NodeOperationError(
|
|
this.getNode(),
|
|
'TeamoRouter returned no image data (expected data[].b64_json)',
|
|
{ itemIndex: i },
|
|
);
|
|
}
|
|
|
|
const format = options.outputFormat ?? 'png';
|
|
const mimeType = MIME_BY_FORMAT[format] ?? 'image/png';
|
|
const extension = EXTENSION_BY_FORMAT[format] ?? 'png';
|
|
const markdown: string[] = [];
|
|
const binary: Record<string, IBinaryData> = {};
|
|
|
|
for (const [index, result] of results.entries()) {
|
|
const propertyName = index === 0 ? binaryPropertyName : `${binaryPropertyName}${index}`;
|
|
if (typeof result.b64_json === 'string' && result.b64_json !== '') {
|
|
binary[propertyName] = await this.helpers.prepareBinaryData(
|
|
Buffer.from(result.b64_json, 'base64'),
|
|
`teamo-image-${Date.now()}-${index}.${extension}`,
|
|
mimeType,
|
|
);
|
|
markdown.push(``);
|
|
} else if (typeof result.url === 'string') {
|
|
markdown.push(``);
|
|
}
|
|
}
|
|
|
|
if (chatReply) {
|
|
returnData.push({
|
|
json: { model: imageModel, prompt, output: markdown.join('\n\n') },
|
|
...(Object.keys(binary).length > 0 ? { binary } : {}),
|
|
pairedItem: { item: i },
|
|
});
|
|
continue;
|
|
}
|
|
|
|
results.forEach((result, index) => {
|
|
const propertyName = index === 0 ? binaryPropertyName : `${binaryPropertyName}${index}`;
|
|
const json: IDataObject = { model: imageModel, prompt };
|
|
if (results.length > 1) json.index = index;
|
|
if (typeof result.url === 'string' && result.url !== '') json.url = result.url;
|
|
returnData.push({
|
|
json,
|
|
...(binary[propertyName] !== undefined
|
|
? { binary: { [propertyName]: binary[propertyName] } }
|
|
: {}),
|
|
pairedItem: { item: i },
|
|
});
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const response = await callApi<{ data?: TeamorouterModel[] } | TeamorouterModel[]>(this, {
|
|
method: 'GET',
|
|
baseURL,
|
|
url: '/models',
|
|
json: true,
|
|
timeout: 60000,
|
|
});
|
|
const models = Array.isArray(response) ? response : response?.data ?? [];
|
|
returnData.push(
|
|
...this.helpers.constructExecutionMetaData(
|
|
this.helpers.returnJsonArray(models as unknown as IDataObject[]),
|
|
{ itemData: { item: i } },
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (this.continueOnFail()) {
|
|
returnData.push(
|
|
...this.helpers.constructExecutionMetaData(
|
|
this.helpers.returnJsonArray({ error: errorMessage(error) }),
|
|
{ itemData: { item: i } },
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return [returnData];
|
|
}
|
|
}
|