Skip to content

Commit

Permalink
feat: Add Ask AI to HTTP Request Node (#8917)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexgrozav committed May 2, 2024
1 parent 7ff24f1 commit cd9bc44
Show file tree
Hide file tree
Showing 40 changed files with 3,943 additions and 369 deletions.
7 changes: 6 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,15 @@
"@langchain/community": "0.0.53",
"@langchain/core": "0.1.61",
"@langchain/openai": "0.0.28",
"@langchain/pinecone": "^0.0.3",
"@n8n/client-oauth2": "workspace:*",
"@n8n/localtunnel": "2.1.0",
"@n8n/n8n-nodes-langchain": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/typeorm": "0.3.20-9",
"@n8n_io/license-sdk": "2.10.0",
"@oclif/core": "3.18.1",
"@pinecone-database/pinecone": "2.1.0",
"@rudderstack/rudder-sdk-node": "2.0.7",
"@sentry/integrations": "7.87.0",
"@sentry/node": "7.87.0",
Expand Down Expand Up @@ -128,6 +130,7 @@
"fast-glob": "3.2.12",
"flatted": "3.2.7",
"formidable": "3.5.1",
"fuse.js": "^7.0.0",
"google-timezones-json": "1.1.0",
"handlebars": "4.7.8",
"helmet": "7.1.0",
Expand Down Expand Up @@ -181,6 +184,8 @@
"ws": "8.14.2",
"xml2js": "0.6.2",
"xmllint-wasm": "3.0.1",
"yamljs": "0.3.0"
"yamljs": "0.3.0",
"zod": "3.22.4",
"zod-to-json-schema": "3.22.4"
}
}
2 changes: 1 addition & 1 deletion packages/cli/src/CurlConverterHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ export const toHttpNodeParameters = (curlCommand: string): HttpNodeParameters =>
// json body
Object.assign(httpNodeParameters, {
specifyBody: 'json',
jsonBody: JSON.stringify(json),
jsonBody: JSON.stringify(json, null, 2),
});
} else {
// key-value body
Expand Down
26 changes: 21 additions & 5 deletions packages/cli/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,11 +1356,27 @@ export const schema = {
default: 'openai',
env: 'N8N_AI_PROVIDER',
},
openAIApiKey: {
doc: 'Enable AI features using OpenAI API key',
format: String,
default: '',
env: 'N8N_AI_OPENAI_API_KEY',
openAI: {
apiKey: {
doc: 'Enable AI features using OpenAI API key',
format: String,
default: '',
env: 'N8N_AI_OPENAI_API_KEY',
},
model: {
doc: 'OpenAI model to use',
format: String,
default: 'gpt-4-turbo',
env: 'N8N_AI_OPENAI_MODEL',
},
},
pinecone: {
apiKey: {
doc: 'Enable AI features using Pinecone API key',
format: String,
default: '',
env: 'N8N_AI_PINECONE_API_KEY',
},
},
},

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,5 @@ export const MAX_PASSWORD_CHAR_LENGTH = 64;
export const TEST_WEBHOOK_TIMEOUT = 2 * TIME.MINUTE;

export const TEST_WEBHOOK_TIMEOUT_BUFFER = 30 * TIME.SECOND;

export const N8N_DOCS_URL = 'https://docs.n8n.io';
17 changes: 17 additions & 0 deletions packages/cli/src/controllers/ai.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,21 @@ export class AIController {
);
}
}

/**
* Generate CURL request and additional HTTP Node metadata for given service and request
*/
@Post('/generate-curl')
async generateCurl(req: AIRequest.GenerateCurl): Promise<{ curl: string; metadata?: object }> {
const { service, request } = req.body;

try {
return await this.aiService.generateCurl(service, request);
} catch (aiServiceError) {
throw new FailedDependencyError(
(aiServiceError as Error).message ||
'Failed to generate HTTP Request Node parameters due to an issue with an external dependency. Please try again later.',
);
}
}
}
2 changes: 1 addition & 1 deletion packages/cli/src/controllers/passwordReset.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class PasswordResetController {
if (
isSamlCurrentAuthenticationMethod() &&
!(
(user && user.hasGlobalScope('user:resetPassword')) === true ||
user?.hasGlobalScope('user:resetPassword') === true ||
user?.settings?.allowSSOManualLogin === true
)
) {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,18 @@ export function hasSharing(

export declare namespace AIRequest {
export type DebugError = AuthenticatedRequest<{}, {}, AIDebugErrorPayload>;
export type GenerateCurl = AuthenticatedRequest<{}, {}, AIGenerateCurlPayload>;
}

export interface AIDebugErrorPayload {
error: NodeError;
}

export interface AIGenerateCurlPayload {
service: string;
request: string;
}

// ----------------------------------
// /credentials
// ----------------------------------
Expand Down
196 changes: 184 additions & 12 deletions packages/cli/src/services/ai.service.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,212 @@
import { Service } from 'typedi';
import config from '@/config';
import type { INodeType, N8nAIProviderType, NodeError } from 'n8n-workflow';
import { createDebugErrorPrompt } from '@/services/ai/prompts/debugError';
import { ApplicationError, jsonParse } from 'n8n-workflow';
import { debugErrorPromptTemplate } from '@/services/ai/prompts/debugError';
import type { BaseMessageLike } from '@langchain/core/messages';
import { AIProviderOpenAI } from '@/services/ai/providers/openai';
import { AIProviderUnknown } from '@/services/ai/providers/unknown';
import type { BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
import { summarizeNodeTypeProperties } from '@/services/ai/utils/summarizeNodeTypeProperties';
import { Pinecone } from '@pinecone-database/pinecone';
import type { z } from 'zod';
import apiKnowledgebase from '@/services/ai/resources/api-knowledgebase.json';
import { JsonOutputFunctionsParser } from 'langchain/output_parsers';
import {
generateCurlCommandFallbackPromptTemplate,
generateCurlCommandPromptTemplate,
} from '@/services/ai/prompts/generateCurl';
import { generateCurlSchema } from '@/services/ai/schemas/generateCurl';
import { PineconeStore } from '@langchain/pinecone';
import Fuse from 'fuse.js';
import { N8N_DOCS_URL } from '@/constants';

interface APIKnowledgebaseService {
id: string;
title: string;
description?: string;
}

function isN8nAIProviderType(value: string): value is N8nAIProviderType {
return ['openai'].includes(value);
}

@Service()
export class AIService {
private provider: N8nAIProviderType = 'unknown';
private providerType: N8nAIProviderType = 'unknown';

public provider: AIProviderOpenAI;

public model: AIProviderOpenAI | AIProviderUnknown = new AIProviderUnknown();
public pinecone: Pinecone;

private jsonOutputParser = new JsonOutputFunctionsParser();

constructor() {
const providerName = config.getEnv('ai.provider');

if (isN8nAIProviderType(providerName)) {
this.provider = providerName;
this.providerType = providerName;
}

if (this.provider === 'openai') {
const apiKey = config.getEnv('ai.openAIApiKey');
if (apiKey) {
this.model = new AIProviderOpenAI({ apiKey });
if (this.providerType === 'openai') {
const openAIApiKey = config.getEnv('ai.openAI.apiKey');
const openAIModelName = config.getEnv('ai.openAI.model');

if (openAIApiKey) {
this.provider = new AIProviderOpenAI({ openAIApiKey, modelName: openAIModelName });
}
}

const pineconeApiKey = config.getEnv('ai.pinecone.apiKey');
if (pineconeApiKey) {
this.pinecone = new Pinecone({
apiKey: pineconeApiKey,
});
}
}

async prompt(messages: BaseMessageLike[]) {
return await this.model.prompt(messages);
async prompt(messages: BaseMessageLike[], options?: BaseChatModelCallOptions) {
if (!this.provider) {
throw new ApplicationError('No AI provider has been configured.');
}

return await this.provider.invoke(messages, options);
}

async debugError(error: NodeError, nodeType?: INodeType) {
return await this.prompt(createDebugErrorPrompt(error, nodeType));
this.checkRequirements();

const chain = debugErrorPromptTemplate.pipe(this.provider.model);
const result = await chain.invoke({
nodeType: nodeType?.description.displayName ?? 'n8n Node',
error: JSON.stringify(error),
properties: JSON.stringify(
summarizeNodeTypeProperties(nodeType?.description.properties ?? []),
),
documentationUrl: nodeType?.description.documentationUrl ?? N8N_DOCS_URL,
});

return this.provider.mapResponse(result);
}

validateCurl(result: { curl: string }) {
if (!result.curl.startsWith('curl')) {
throw new ApplicationError(
'The generated HTTP Request Node parameters format is incorrect. Please adjust your request and try again.',
);
}

result.curl = result.curl
/*
* Replaces placeholders like `{VALUE}` or `{{VALUE}}` with quoted placeholders `"{VALUE}"` or `"{{VALUE}}"`,
* ensuring that the placeholders are properly formatted within the curl command.
* - ": a colon followed by a double quote and a space
* - ( starts a capturing group
* - \{\{ two opening curly braces
* - [A-Za-z0-9_]+ one or more alphanumeric characters or underscores
* - }} two closing curly braces
* - | OR
* - \{ an opening curly brace
* - [A-Za-z0-9_]+ one or more alphanumeric characters or underscores
* - } a closing curly brace
* - ) ends the capturing group
* - /g performs a global search and replace
*
*/
.replace(/": (\{\{[A-Za-z0-9_]+}}|\{[A-Za-z0-9_]+})/g, '": "$1"') // Fix for placeholders `curl -d '{ "key": {VALUE} }'`
/*
* Removes the rogue curly bracket at the end of the curl command if it is present.
* It ensures that the curl command is properly formatted and doesn't have an extra closing curly bracket.
* - ( starts a capturing group
* - -d flag in the curl command
* - ' a single quote
* - [^']+ one or more characters that are not a single quote
* - ' a single quote
* - ) ends the capturing group
* - } a closing curly bracket
*/
.replace(/(-d '[^']+')}/, '$1'); // Fix for rogue curly bracket `curl -d '{ "key": "value" }'}`

return result;
}

async generateCurl(serviceName: string, serviceRequest: string) {
this.checkRequirements();

if (!this.pinecone) {
return await this.generateCurlGeneric(serviceName, serviceRequest);
}

const fuse = new Fuse(apiKnowledgebase as unknown as APIKnowledgebaseService[], {
threshold: 0.25,
useExtendedSearch: true,
keys: ['id', 'title'],
});

const matchedServices = fuse
.search(serviceName.replace(/ +/g, '|'))
.map((result) => result.item);

if (matchedServices.length === 0) {
return await this.generateCurlGeneric(serviceName, serviceRequest);
}

const pcIndex = this.pinecone.Index('api-knowledgebase');
const vectorStore = await PineconeStore.fromExistingIndex(this.provider.embeddings, {
namespace: 'endpoints',
pineconeIndex: pcIndex,
});

const matchedDocuments = await vectorStore.similaritySearch(
`${serviceName} ${serviceRequest}`,
4,
{
id: {
$in: matchedServices.map((service) => service.id),
},
},
);

if (matchedDocuments.length === 0) {
return await this.generateCurlGeneric(serviceName, serviceRequest);
}

const aggregatedDocuments = matchedDocuments.reduce<unknown[]>((acc, document) => {
const pageData = jsonParse(document.pageContent);

acc.push(pageData);

return acc;
}, []);

const generateCurlChain = generateCurlCommandPromptTemplate
.pipe(this.provider.modelWithOutputParser(generateCurlSchema))
.pipe(this.jsonOutputParser);
const result = (await generateCurlChain.invoke({
endpoints: JSON.stringify(aggregatedDocuments),
serviceName,
serviceRequest,
})) as z.infer<typeof generateCurlSchema>;

return this.validateCurl(result);
}

async generateCurlGeneric(serviceName: string, serviceRequest: string) {
this.checkRequirements();

const generateCurlFallbackChain = generateCurlCommandFallbackPromptTemplate
.pipe(this.provider.modelWithOutputParser(generateCurlSchema))
.pipe(this.jsonOutputParser);
const result = (await generateCurlFallbackChain.invoke({
serviceName,
serviceRequest,
})) as z.infer<typeof generateCurlSchema>;

return this.validateCurl(result);
}

checkRequirements() {
if (!this.provider) {
throw new ApplicationError('No AI provider has been configured.');
}
}
}
Loading

0 comments on commit cd9bc44

Please sign in to comment.