Skip to content

Commit cd9bc44

Browse files
authored
feat: Add Ask AI to HTTP Request Node (#8917)
1 parent 7ff24f1 commit cd9bc44

40 files changed

+3943
-369
lines changed

packages/cli/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,15 @@
9393
"@langchain/community": "0.0.53",
9494
"@langchain/core": "0.1.61",
9595
"@langchain/openai": "0.0.28",
96+
"@langchain/pinecone": "^0.0.3",
9697
"@n8n/client-oauth2": "workspace:*",
9798
"@n8n/localtunnel": "2.1.0",
9899
"@n8n/n8n-nodes-langchain": "workspace:*",
99100
"@n8n/permissions": "workspace:*",
100101
"@n8n/typeorm": "0.3.20-9",
101102
"@n8n_io/license-sdk": "2.10.0",
102103
"@oclif/core": "3.18.1",
104+
"@pinecone-database/pinecone": "2.1.0",
103105
"@rudderstack/rudder-sdk-node": "2.0.7",
104106
"@sentry/integrations": "7.87.0",
105107
"@sentry/node": "7.87.0",
@@ -128,6 +130,7 @@
128130
"fast-glob": "3.2.12",
129131
"flatted": "3.2.7",
130132
"formidable": "3.5.1",
133+
"fuse.js": "^7.0.0",
131134
"google-timezones-json": "1.1.0",
132135
"handlebars": "4.7.8",
133136
"helmet": "7.1.0",
@@ -181,6 +184,8 @@
181184
"ws": "8.14.2",
182185
"xml2js": "0.6.2",
183186
"xmllint-wasm": "3.0.1",
184-
"yamljs": "0.3.0"
187+
"yamljs": "0.3.0",
188+
"zod": "3.22.4",
189+
"zod-to-json-schema": "3.22.4"
185190
}
186191
}

packages/cli/src/CurlConverterHelper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ export const toHttpNodeParameters = (curlCommand: string): HttpNodeParameters =>
417417
// json body
418418
Object.assign(httpNodeParameters, {
419419
specifyBody: 'json',
420-
jsonBody: JSON.stringify(json),
420+
jsonBody: JSON.stringify(json, null, 2),
421421
});
422422
} else {
423423
// key-value body

packages/cli/src/config/schema.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,11 +1356,27 @@ export const schema = {
13561356
default: 'openai',
13571357
env: 'N8N_AI_PROVIDER',
13581358
},
1359-
openAIApiKey: {
1360-
doc: 'Enable AI features using OpenAI API key',
1361-
format: String,
1362-
default: '',
1363-
env: 'N8N_AI_OPENAI_API_KEY',
1359+
openAI: {
1360+
apiKey: {
1361+
doc: 'Enable AI features using OpenAI API key',
1362+
format: String,
1363+
default: '',
1364+
env: 'N8N_AI_OPENAI_API_KEY',
1365+
},
1366+
model: {
1367+
doc: 'OpenAI model to use',
1368+
format: String,
1369+
default: 'gpt-4-turbo',
1370+
env: 'N8N_AI_OPENAI_MODEL',
1371+
},
1372+
},
1373+
pinecone: {
1374+
apiKey: {
1375+
doc: 'Enable AI features using Pinecone API key',
1376+
format: String,
1377+
default: '',
1378+
env: 'N8N_AI_PINECONE_API_KEY',
1379+
},
13641380
},
13651381
},
13661382

packages/cli/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,5 @@ export const MAX_PASSWORD_CHAR_LENGTH = 64;
142142
export const TEST_WEBHOOK_TIMEOUT = 2 * TIME.MINUTE;
143143

144144
export const TEST_WEBHOOK_TIMEOUT_BUFFER = 30 * TIME.SECOND;
145+
146+
export const N8N_DOCS_URL = 'https://docs.n8n.io';

packages/cli/src/controllers/ai.controller.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,21 @@ export class AIController {
3535
);
3636
}
3737
}
38+
39+
/**
40+
* Generate CURL request and additional HTTP Node metadata for given service and request
41+
*/
42+
@Post('/generate-curl')
43+
async generateCurl(req: AIRequest.GenerateCurl): Promise<{ curl: string; metadata?: object }> {
44+
const { service, request } = req.body;
45+
46+
try {
47+
return await this.aiService.generateCurl(service, request);
48+
} catch (aiServiceError) {
49+
throw new FailedDependencyError(
50+
(aiServiceError as Error).message ||
51+
'Failed to generate HTTP Request Node parameters due to an issue with an external dependency. Please try again later.',
52+
);
53+
}
54+
}
3855
}

packages/cli/src/controllers/passwordReset.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export class PasswordResetController {
8181
if (
8282
isSamlCurrentAuthenticationMethod() &&
8383
!(
84-
(user && user.hasGlobalScope('user:resetPassword')) === true ||
84+
user?.hasGlobalScope('user:resetPassword') === true ||
8585
user?.settings?.allowSSOManualLogin === true
8686
)
8787
) {

packages/cli/src/requests.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,18 @@ export function hasSharing(
150150

151151
export declare namespace AIRequest {
152152
export type DebugError = AuthenticatedRequest<{}, {}, AIDebugErrorPayload>;
153+
export type GenerateCurl = AuthenticatedRequest<{}, {}, AIGenerateCurlPayload>;
153154
}
154155

155156
export interface AIDebugErrorPayload {
156157
error: NodeError;
157158
}
158159

160+
export interface AIGenerateCurlPayload {
161+
service: string;
162+
request: string;
163+
}
164+
159165
// ----------------------------------
160166
// /credentials
161167
// ----------------------------------
Lines changed: 184 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,212 @@
11
import { Service } from 'typedi';
22
import config from '@/config';
33
import type { INodeType, N8nAIProviderType, NodeError } from 'n8n-workflow';
4-
import { createDebugErrorPrompt } from '@/services/ai/prompts/debugError';
4+
import { ApplicationError, jsonParse } from 'n8n-workflow';
5+
import { debugErrorPromptTemplate } from '@/services/ai/prompts/debugError';
56
import type { BaseMessageLike } from '@langchain/core/messages';
67
import { AIProviderOpenAI } from '@/services/ai/providers/openai';
7-
import { AIProviderUnknown } from '@/services/ai/providers/unknown';
8+
import type { BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
9+
import { summarizeNodeTypeProperties } from '@/services/ai/utils/summarizeNodeTypeProperties';
10+
import { Pinecone } from '@pinecone-database/pinecone';
11+
import type { z } from 'zod';
12+
import apiKnowledgebase from '@/services/ai/resources/api-knowledgebase.json';
13+
import { JsonOutputFunctionsParser } from 'langchain/output_parsers';
14+
import {
15+
generateCurlCommandFallbackPromptTemplate,
16+
generateCurlCommandPromptTemplate,
17+
} from '@/services/ai/prompts/generateCurl';
18+
import { generateCurlSchema } from '@/services/ai/schemas/generateCurl';
19+
import { PineconeStore } from '@langchain/pinecone';
20+
import Fuse from 'fuse.js';
21+
import { N8N_DOCS_URL } from '@/constants';
22+
23+
interface APIKnowledgebaseService {
24+
id: string;
25+
title: string;
26+
description?: string;
27+
}
828

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

1333
@Service()
1434
export class AIService {
15-
private provider: N8nAIProviderType = 'unknown';
35+
private providerType: N8nAIProviderType = 'unknown';
36+
37+
public provider: AIProviderOpenAI;
1638

17-
public model: AIProviderOpenAI | AIProviderUnknown = new AIProviderUnknown();
39+
public pinecone: Pinecone;
40+
41+
private jsonOutputParser = new JsonOutputFunctionsParser();
1842

1943
constructor() {
2044
const providerName = config.getEnv('ai.provider');
45+
2146
if (isN8nAIProviderType(providerName)) {
22-
this.provider = providerName;
47+
this.providerType = providerName;
2348
}
2449

25-
if (this.provider === 'openai') {
26-
const apiKey = config.getEnv('ai.openAIApiKey');
27-
if (apiKey) {
28-
this.model = new AIProviderOpenAI({ apiKey });
50+
if (this.providerType === 'openai') {
51+
const openAIApiKey = config.getEnv('ai.openAI.apiKey');
52+
const openAIModelName = config.getEnv('ai.openAI.model');
53+
54+
if (openAIApiKey) {
55+
this.provider = new AIProviderOpenAI({ openAIApiKey, modelName: openAIModelName });
2956
}
3057
}
58+
59+
const pineconeApiKey = config.getEnv('ai.pinecone.apiKey');
60+
if (pineconeApiKey) {
61+
this.pinecone = new Pinecone({
62+
apiKey: pineconeApiKey,
63+
});
64+
}
3165
}
3266

33-
async prompt(messages: BaseMessageLike[]) {
34-
return await this.model.prompt(messages);
67+
async prompt(messages: BaseMessageLike[], options?: BaseChatModelCallOptions) {
68+
if (!this.provider) {
69+
throw new ApplicationError('No AI provider has been configured.');
70+
}
71+
72+
return await this.provider.invoke(messages, options);
3573
}
3674

3775
async debugError(error: NodeError, nodeType?: INodeType) {
38-
return await this.prompt(createDebugErrorPrompt(error, nodeType));
76+
this.checkRequirements();
77+
78+
const chain = debugErrorPromptTemplate.pipe(this.provider.model);
79+
const result = await chain.invoke({
80+
nodeType: nodeType?.description.displayName ?? 'n8n Node',
81+
error: JSON.stringify(error),
82+
properties: JSON.stringify(
83+
summarizeNodeTypeProperties(nodeType?.description.properties ?? []),
84+
),
85+
documentationUrl: nodeType?.description.documentationUrl ?? N8N_DOCS_URL,
86+
});
87+
88+
return this.provider.mapResponse(result);
89+
}
90+
91+
validateCurl(result: { curl: string }) {
92+
if (!result.curl.startsWith('curl')) {
93+
throw new ApplicationError(
94+
'The generated HTTP Request Node parameters format is incorrect. Please adjust your request and try again.',
95+
);
96+
}
97+
98+
result.curl = result.curl
99+
/*
100+
* Replaces placeholders like `{VALUE}` or `{{VALUE}}` with quoted placeholders `"{VALUE}"` or `"{{VALUE}}"`,
101+
* ensuring that the placeholders are properly formatted within the curl command.
102+
* - ": a colon followed by a double quote and a space
103+
* - ( starts a capturing group
104+
* - \{\{ two opening curly braces
105+
* - [A-Za-z0-9_]+ one or more alphanumeric characters or underscores
106+
* - }} two closing curly braces
107+
* - | OR
108+
* - \{ an opening curly brace
109+
* - [A-Za-z0-9_]+ one or more alphanumeric characters or underscores
110+
* - } a closing curly brace
111+
* - ) ends the capturing group
112+
* - /g performs a global search and replace
113+
*
114+
*/
115+
.replace(/": (\{\{[A-Za-z0-9_]+}}|\{[A-Za-z0-9_]+})/g, '": "$1"') // Fix for placeholders `curl -d '{ "key": {VALUE} }'`
116+
/*
117+
* Removes the rogue curly bracket at the end of the curl command if it is present.
118+
* It ensures that the curl command is properly formatted and doesn't have an extra closing curly bracket.
119+
* - ( starts a capturing group
120+
* - -d flag in the curl command
121+
* - ' a single quote
122+
* - [^']+ one or more characters that are not a single quote
123+
* - ' a single quote
124+
* - ) ends the capturing group
125+
* - } a closing curly bracket
126+
*/
127+
.replace(/(-d '[^']+')}/, '$1'); // Fix for rogue curly bracket `curl -d '{ "key": "value" }'}`
128+
129+
return result;
130+
}
131+
132+
async generateCurl(serviceName: string, serviceRequest: string) {
133+
this.checkRequirements();
134+
135+
if (!this.pinecone) {
136+
return await this.generateCurlGeneric(serviceName, serviceRequest);
137+
}
138+
139+
const fuse = new Fuse(apiKnowledgebase as unknown as APIKnowledgebaseService[], {
140+
threshold: 0.25,
141+
useExtendedSearch: true,
142+
keys: ['id', 'title'],
143+
});
144+
145+
const matchedServices = fuse
146+
.search(serviceName.replace(/ +/g, '|'))
147+
.map((result) => result.item);
148+
149+
if (matchedServices.length === 0) {
150+
return await this.generateCurlGeneric(serviceName, serviceRequest);
151+
}
152+
153+
const pcIndex = this.pinecone.Index('api-knowledgebase');
154+
const vectorStore = await PineconeStore.fromExistingIndex(this.provider.embeddings, {
155+
namespace: 'endpoints',
156+
pineconeIndex: pcIndex,
157+
});
158+
159+
const matchedDocuments = await vectorStore.similaritySearch(
160+
`${serviceName} ${serviceRequest}`,
161+
4,
162+
{
163+
id: {
164+
$in: matchedServices.map((service) => service.id),
165+
},
166+
},
167+
);
168+
169+
if (matchedDocuments.length === 0) {
170+
return await this.generateCurlGeneric(serviceName, serviceRequest);
171+
}
172+
173+
const aggregatedDocuments = matchedDocuments.reduce<unknown[]>((acc, document) => {
174+
const pageData = jsonParse(document.pageContent);
175+
176+
acc.push(pageData);
177+
178+
return acc;
179+
}, []);
180+
181+
const generateCurlChain = generateCurlCommandPromptTemplate
182+
.pipe(this.provider.modelWithOutputParser(generateCurlSchema))
183+
.pipe(this.jsonOutputParser);
184+
const result = (await generateCurlChain.invoke({
185+
endpoints: JSON.stringify(aggregatedDocuments),
186+
serviceName,
187+
serviceRequest,
188+
})) as z.infer<typeof generateCurlSchema>;
189+
190+
return this.validateCurl(result);
191+
}
192+
193+
async generateCurlGeneric(serviceName: string, serviceRequest: string) {
194+
this.checkRequirements();
195+
196+
const generateCurlFallbackChain = generateCurlCommandFallbackPromptTemplate
197+
.pipe(this.provider.modelWithOutputParser(generateCurlSchema))
198+
.pipe(this.jsonOutputParser);
199+
const result = (await generateCurlFallbackChain.invoke({
200+
serviceName,
201+
serviceRequest,
202+
})) as z.infer<typeof generateCurlSchema>;
203+
204+
return this.validateCurl(result);
205+
}
206+
207+
checkRequirements() {
208+
if (!this.provider) {
209+
throw new ApplicationError('No AI provider has been configured.');
210+
}
39211
}
40212
}

0 commit comments

Comments
 (0)