Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Ask AI to HTTP Request Node #8917

Merged
merged 37 commits into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
c7bf3aa
feat: add HTTP Request node Ai integration
alexgrozav Mar 15, 2024
1fd6345
feat: update provider structure and add openapi-directory integration
alexgrozav Mar 18, 2024
633c2ba
chore: remove console.log
alexgrozav Mar 18, 2024
405fdc4
feat: cleanup and translations update
alexgrozav Mar 19, 2024
f83eacb
chore: merge master
alexgrozav Mar 19, 2024
195a504
fix: update deps
alexgrozav Mar 19, 2024
9704a63
fix: remove unnecessary gitignore
alexgrozav Mar 19, 2024
72552f5
fix: update deps
alexgrozav Mar 19, 2024
8efdb26
feat: update curl generation integration
alexgrozav Mar 26, 2024
639ed81
chore: remove openapi scripts
alexgrozav Mar 26, 2024
979501c
fix: update dependencies
alexgrozav Mar 26, 2024
0488f1b
feat: update curl import and knowledgebase
alexgrozav Mar 29, 2024
14df23d
fix: update search algorithm to take spaces into account
alexgrozav Mar 29, 2024
579dd48
feat: adjust prompt for better results
alexgrozav Mar 29, 2024
c462218
test: update tests
alexgrozav Apr 3, 2024
1a300bc
chore: merge master
alexgrozav Apr 3, 2024
2d6e1aa
fix: remove unused gitignore
alexgrozav Apr 3, 2024
293a399
test: add generateCurl test
alexgrozav Apr 3, 2024
884d3b8
fix: fix linting issue
alexgrozav Apr 3, 2024
eaaf307
feat: various ux improvements
alexgrozav Apr 4, 2024
dfed1d2
chore: merge master
alexgrozav Apr 4, 2024
d393138
fix: update tests
alexgrozav Apr 5, 2024
25fa4cb
feat: update knowledgebase
alexgrozav Apr 9, 2024
f23c2be
feat: add telemetry
alexgrozav Apr 10, 2024
a6b2c70
feat: update prompt to ensure validity
alexgrozav Apr 10, 2024
555277b
fix: fix generated command rogue brackets using regex
alexgrozav Apr 10, 2024
76cb717
refactor: add checkRequirements method
alexgrozav Apr 10, 2024
131a215
chore: merge master
alexgrozav Apr 10, 2024
82ed5ce
fix: update telemetry
alexgrozav Apr 10, 2024
6a24efb
chore: merge master
alexgrozav Apr 22, 2024
1889090
fix: remove duplicate telemetry events
alexgrozav Apr 22, 2024
86c0248
fix: extract docs url and update incorrect curl command error message
alexgrozav Apr 26, 2024
25187d7
feat: further improvements
alexgrozav Apr 26, 2024
0d1cded
fix: update css to use css variable
alexgrozav Apr 26, 2024
3b8cdf3
feat: add ai resources README
alexgrozav Apr 26, 2024
6afec0c
chore: merge master
alexgrozav Apr 26, 2024
a5155fc
chore: merge master
alexgrozav May 2, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
OlegIvaniv marked this conversation as resolved.
Show resolved Hide resolved

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
Loading