@lhi/n8m 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/bin/dev.js +5 -0
- package/bin/run.js +6 -0
- package/dist/agentic/checkpointer.d.ts +2 -0
- package/dist/agentic/checkpointer.js +14 -0
- package/dist/agentic/graph.d.ts +483 -0
- package/dist/agentic/graph.js +100 -0
- package/dist/agentic/nodes/architect.d.ts +6 -0
- package/dist/agentic/nodes/architect.js +51 -0
- package/dist/agentic/nodes/engineer.d.ts +11 -0
- package/dist/agentic/nodes/engineer.js +182 -0
- package/dist/agentic/nodes/qa.d.ts +5 -0
- package/dist/agentic/nodes/qa.js +151 -0
- package/dist/agentic/nodes/reviewer.d.ts +5 -0
- package/dist/agentic/nodes/reviewer.js +111 -0
- package/dist/agentic/nodes/supervisor.d.ts +6 -0
- package/dist/agentic/nodes/supervisor.js +18 -0
- package/dist/agentic/state.d.ts +51 -0
- package/dist/agentic/state.js +26 -0
- package/dist/commands/config.d.ts +13 -0
- package/dist/commands/config.js +47 -0
- package/dist/commands/create.d.ts +14 -0
- package/dist/commands/create.js +182 -0
- package/dist/commands/deploy.d.ts +13 -0
- package/dist/commands/deploy.js +68 -0
- package/dist/commands/modify.d.ts +13 -0
- package/dist/commands/modify.js +276 -0
- package/dist/commands/prune.d.ts +9 -0
- package/dist/commands/prune.js +98 -0
- package/dist/commands/resume.d.ts +8 -0
- package/dist/commands/resume.js +39 -0
- package/dist/commands/test.d.ts +27 -0
- package/dist/commands/test.js +619 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/ai.service.d.ts +51 -0
- package/dist/services/ai.service.js +421 -0
- package/dist/services/n8n.service.d.ts +17 -0
- package/dist/services/n8n.service.js +81 -0
- package/dist/services/node-definitions.service.d.ts +36 -0
- package/dist/services/node-definitions.service.js +102 -0
- package/dist/utils/config.d.ts +15 -0
- package/dist/utils/config.js +25 -0
- package/dist/utils/multilinePrompt.d.ts +1 -0
- package/dist/utils/multilinePrompt.js +52 -0
- package/dist/utils/n8nClient.d.ts +97 -0
- package/dist/utils/n8nClient.js +440 -0
- package/dist/utils/sandbox.d.ts +13 -0
- package/dist/utils/sandbox.js +34 -0
- package/dist/utils/theme.d.ts +23 -0
- package/dist/utils/theme.js +92 -0
- package/oclif.manifest.json +331 -0
- package/package.json +95 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import fsSync from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
// Base URLs and default models for known providers
|
|
6
|
+
const PROVIDER_PRESETS = {
|
|
7
|
+
openai: {
|
|
8
|
+
defaultModel: 'gpt-4o',
|
|
9
|
+
},
|
|
10
|
+
anthropic: {
|
|
11
|
+
baseURL: 'https://api.anthropic.com/v1',
|
|
12
|
+
defaultModel: 'claude-sonnet-4-6',
|
|
13
|
+
},
|
|
14
|
+
gemini: {
|
|
15
|
+
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
|
16
|
+
defaultModel: 'gemini-2.5-flash',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export class AIService {
|
|
20
|
+
static instance;
|
|
21
|
+
client;
|
|
22
|
+
model;
|
|
23
|
+
constructor() {
|
|
24
|
+
// Load persisted config from ~/.n8m/config.json as fallback for env vars
|
|
25
|
+
let fileConfig = {};
|
|
26
|
+
try {
|
|
27
|
+
const configFile = path.join(os.homedir(), '.n8m', 'config.json');
|
|
28
|
+
fileConfig = JSON.parse(fsSync.readFileSync(configFile, 'utf-8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Config file doesn't exist yet — that's fine
|
|
32
|
+
}
|
|
33
|
+
let apiKey = process.env.AI_API_KEY || fileConfig['aiKey'];
|
|
34
|
+
let baseURL = process.env.AI_BASE_URL || fileConfig['aiBaseUrl'];
|
|
35
|
+
let provider = (process.env.AI_PROVIDER || fileConfig['aiProvider'])?.toLowerCase();
|
|
36
|
+
// Backward compat: GEMINI_API_KEY still works
|
|
37
|
+
if (!apiKey && process.env.GEMINI_API_KEY) {
|
|
38
|
+
apiKey = process.env.GEMINI_API_KEY;
|
|
39
|
+
if (!provider)
|
|
40
|
+
provider = 'gemini';
|
|
41
|
+
}
|
|
42
|
+
if (!apiKey) {
|
|
43
|
+
console.warn('⚠️ No AI key found. Run: n8m config --ai-key <your-key> --ai-provider openai');
|
|
44
|
+
}
|
|
45
|
+
const preset = provider ? PROVIDER_PRESETS[provider] : undefined;
|
|
46
|
+
// Apply preset base URL unless the user explicitly set one
|
|
47
|
+
if (preset?.baseURL && !baseURL) {
|
|
48
|
+
baseURL = preset.baseURL;
|
|
49
|
+
}
|
|
50
|
+
this.model = process.env.AI_MODEL || fileConfig['aiModel'] || preset?.defaultModel || 'gpt-4o';
|
|
51
|
+
this.client = new OpenAI({
|
|
52
|
+
apiKey: apiKey || 'no-key',
|
|
53
|
+
...(baseURL ? { baseURL } : {}),
|
|
54
|
+
defaultHeaders: provider === 'anthropic'
|
|
55
|
+
? { 'anthropic-version': '2023-06-01' }
|
|
56
|
+
: undefined,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
static getInstance() {
|
|
60
|
+
if (!AIService.instance) {
|
|
61
|
+
AIService.instance = new AIService();
|
|
62
|
+
}
|
|
63
|
+
return AIService.instance;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Core generation method — works with any OpenAI-compatible API
|
|
67
|
+
*/
|
|
68
|
+
async generateContent(prompt, options = {}) {
|
|
69
|
+
const model = options.model || this.model;
|
|
70
|
+
const maxRetries = 3;
|
|
71
|
+
let lastError;
|
|
72
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
if (process.stdout.isTTY) {
|
|
75
|
+
process.stdout.write(' (AI Thinking...)\n');
|
|
76
|
+
}
|
|
77
|
+
const stream = await this.client.chat.completions.create({
|
|
78
|
+
model,
|
|
79
|
+
messages: [{ role: 'user', content: prompt }],
|
|
80
|
+
temperature: options.temperature ?? 0.7,
|
|
81
|
+
stream: true,
|
|
82
|
+
});
|
|
83
|
+
let text = '';
|
|
84
|
+
for await (const chunk of stream) {
|
|
85
|
+
text += chunk.choices[0]?.delta?.content || '';
|
|
86
|
+
}
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (process.stdout.isTTY)
|
|
91
|
+
process.stdout.write('\n');
|
|
92
|
+
lastError = error;
|
|
93
|
+
const isRetryable = error.status === 503 ||
|
|
94
|
+
error.status === 529 ||
|
|
95
|
+
(error.message && (error.message.includes('fetch failed') ||
|
|
96
|
+
error.message.includes('ECONNRESET') ||
|
|
97
|
+
error.message.includes('timeout')));
|
|
98
|
+
if (attempt < maxRetries && isRetryable) {
|
|
99
|
+
const delay = Math.pow(2, attempt) * 1000;
|
|
100
|
+
console.warn(`[AIService] Attempt ${attempt}/${maxRetries} failed: ${error.message}. Retrying in ${delay / 1000}s...`);
|
|
101
|
+
await new Promise(r => setTimeout(r, delay));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw lastError;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Generate an n8n workflow from a description
|
|
111
|
+
*/
|
|
112
|
+
async generateWorkflow(description) {
|
|
113
|
+
const systemPrompt = `You are an expert n8n workflow architect.
|
|
114
|
+
Your task is to generate a valid n8n workflow JSON based on the user's description.
|
|
115
|
+
|
|
116
|
+
Output ONLY valid JSON. No markdown formatting, no explanations.
|
|
117
|
+
The JSON must follow the n8n workflow schema with 'nodes' and 'connections' arrays.
|
|
118
|
+
|
|
119
|
+
User Description: ${description}`;
|
|
120
|
+
const response = await this.generateContent(systemPrompt);
|
|
121
|
+
let cleanJson = response || '{}';
|
|
122
|
+
cleanJson = cleanJson.replace(/```json\n?|\n?```/g, '').trim();
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(cleanJson);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
console.error('Failed to parse generated workflow JSON', e);
|
|
128
|
+
throw new Error('AI generated invalid JSON');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Generate a Workflow Specification from a description
|
|
133
|
+
*/
|
|
134
|
+
async generateSpec(description) {
|
|
135
|
+
const prompt = `You are an n8n Solutions Architect.
|
|
136
|
+
Convert the following user request into a structured Workflow Specification.
|
|
137
|
+
|
|
138
|
+
The Specification should be a JSON object with:
|
|
139
|
+
1. goal: A clear statement of the objective.
|
|
140
|
+
2. suggestedName: A concise, descriptive name for the main workflow (e.g., "Hourly Bitcoin Price Alert").
|
|
141
|
+
3. tasks: A list of strings, each describing a logical step to achieve the goal.
|
|
142
|
+
4. nodes: Potential n8n nodes involved (e.g. ['Webhook', 'HTTP Request', 'Slack']).
|
|
143
|
+
5. assumptions: Any assumptions made about credentials or environment.
|
|
144
|
+
6. questions: A list of string questions if the request is ambiguous or missing critical details. Empty if clear.
|
|
145
|
+
|
|
146
|
+
Output ONLY valid JSON.
|
|
147
|
+
|
|
148
|
+
User Request: ${description}`;
|
|
149
|
+
const response = await this.generateContent(prompt);
|
|
150
|
+
let cleanJson = response || '{}';
|
|
151
|
+
cleanJson = cleanJson.replace(/```json\n?|\n?```/g, '').trim();
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(cleanJson);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
console.error('Failed to parse generated spec JSON', e);
|
|
157
|
+
throw new Error('AI generated invalid JSON for spec');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Refine a Specification based on user feedback
|
|
162
|
+
*/
|
|
163
|
+
async refineSpec(spec, feedback) {
|
|
164
|
+
const prompt = `You are an n8n Solutions Architect.
|
|
165
|
+
Update the following Workflow Specification based on the user's feedback/answers.
|
|
166
|
+
|
|
167
|
+
Current Specification:
|
|
168
|
+
${JSON.stringify(spec, null, 2)}
|
|
169
|
+
|
|
170
|
+
User Feedback:
|
|
171
|
+
${feedback}
|
|
172
|
+
|
|
173
|
+
Ensure 'questions' is empty if the feedback resolves the ambiguity.
|
|
174
|
+
Output the UPDATED JSON Specification only.`;
|
|
175
|
+
const response = await this.generateContent(prompt);
|
|
176
|
+
let cleanJson = response || '{}';
|
|
177
|
+
cleanJson = cleanJson.replace(/```json\n?|\n?```/g, '').trim();
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(cleanJson);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
console.error('Failed to parse refined spec JSON', e);
|
|
183
|
+
throw new Error('AI generated invalid JSON for refined spec');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Generate workflow JSONs from an approved Specification
|
|
188
|
+
*/
|
|
189
|
+
async generateWorkflowFromSpec(spec) {
|
|
190
|
+
const prompt = `You are an n8n Workflow Engineer.
|
|
191
|
+
Generate the valid n8n workflow JSON(s) based on the following approved Specification.
|
|
192
|
+
|
|
193
|
+
Specification:
|
|
194
|
+
${JSON.stringify(spec, null, 2)}
|
|
195
|
+
|
|
196
|
+
IMPORTANT:
|
|
197
|
+
1. Descriptive Naming: Name nodes descriptively (e.g. "Fetch Bitcoin Price" instead of "HTTP Request").
|
|
198
|
+
2. Multi-Workflow: If the spec requires multiple workflows (e.g. Main + Sub-workflow), generate them all.
|
|
199
|
+
3. Linking: If one workflow calls another (using an 'Execute Workflow' node), use the "suggestedName" of the target workflow as the 'workflowId' parameter value. Do NOT use generic IDs like "SUBWORKFLOW_ID".
|
|
200
|
+
4. Consistency: Ensure the "name" field in each workflow matches one of the suggestedNames from the spec.
|
|
201
|
+
5. Standard Node Types: Use valid n8n-nodes-base types.
|
|
202
|
+
- Use "n8n-nodes-base.rssFeedRead" for RSS reading (NOT "rssFeed").
|
|
203
|
+
- Use "n8n-nodes-base.httpRequest" for API calls.
|
|
204
|
+
- Use "n8n-nodes-base.openAi" for OpenAI.
|
|
205
|
+
- Use "n8n-nodes-base.googleGemini" for Google Gemini.
|
|
206
|
+
- Use "n8n-nodes-base.htmlExtract" for HTML/Cheerio extraction.
|
|
207
|
+
6. Connections Structure: The "connections" object keys MUST BE THE SOURCE NODE NAME. The "node" field inside the connection array MUST BE THE TARGET NODE NAME.
|
|
208
|
+
7. Connection Nesting: Ensure the correct n8n connection structure: "SourceNodeName": { "main": [ [ { "node": "TargetNodeName", "type": "main", "index": 0 } ] ] }.
|
|
209
|
+
|
|
210
|
+
Output a JSON object with this structure:
|
|
211
|
+
{
|
|
212
|
+
"workflows": [
|
|
213
|
+
{ "name": "Workflow Name", "nodes": [...], "connections": {...} }
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
Output ONLY valid JSON. No commentary. No markdown.
|
|
218
|
+
`;
|
|
219
|
+
const response = await this.generateContent(prompt);
|
|
220
|
+
let cleanJson = response || '{}';
|
|
221
|
+
cleanJson = cleanJson.replace(/```json\n?|\n?```/g, '').trim();
|
|
222
|
+
try {
|
|
223
|
+
const result = JSON.parse(cleanJson);
|
|
224
|
+
if (result.workflows && Array.isArray(result.workflows)) {
|
|
225
|
+
result.workflows = result.workflows.map((wf) => this.fixHallucinatedNodes(wf));
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
console.error('Failed to parse workflow JSON from spec', e);
|
|
231
|
+
throw new Error('AI generated invalid JSON for workflow from spec');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Generate mock data for a workflow execution
|
|
236
|
+
*/
|
|
237
|
+
async generateMockData(context, previousFailures = []) {
|
|
238
|
+
let failureContext = '';
|
|
239
|
+
if (previousFailures.length > 0) {
|
|
240
|
+
failureContext = `\n\nIMPORTANT: The following attempts FAILED. Do NOT repeat these patterns.\nErrors:\n${previousFailures.join('\n')}`;
|
|
241
|
+
}
|
|
242
|
+
const systemPrompt = `You are a QA Data Generator.
|
|
243
|
+
Your task is to generate a realistic JSON payload to trigger an n8n workflow.
|
|
244
|
+
|
|
245
|
+
CRITICAL: Output ONLY valid raw JSON. No markdown, no explanations, no "Okay" or "Here is".
|
|
246
|
+
If you include any text outside the JSON, the system will crash.
|
|
247
|
+
|
|
248
|
+
Context: ${context}${failureContext}`;
|
|
249
|
+
const response = await this.generateContent(systemPrompt);
|
|
250
|
+
let cleanJson = response || '{}';
|
|
251
|
+
cleanJson = cleanJson.replace(/```json\n?|\n?```/g, '').trim();
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(cleanJson);
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
console.error('Failed to parse generated mock data', e);
|
|
257
|
+
return { message: 'AI generation failed, fallback data' };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Diagnostic Repair: Fix a workflow based on execution error
|
|
262
|
+
*/
|
|
263
|
+
async generateWorkflowFix(workflowJson, errorContext, model, _useSearch = false, validNodeTypes = []) {
|
|
264
|
+
const prompt = `You are a Senior n8n Workflow Engineer.
|
|
265
|
+
A workflow failed during execution. Your task is to analyze the JSON and the Error, and provide a FIXED version of the workflow JSON.
|
|
266
|
+
|
|
267
|
+
Error Context:
|
|
268
|
+
${errorContext}
|
|
269
|
+
|
|
270
|
+
Workflow JSON:
|
|
271
|
+
${JSON.stringify(workflowJson, null, 2)}
|
|
272
|
+
|
|
273
|
+
Review the nodes involved in the error.
|
|
274
|
+
${validNodeTypes.length > 0 ? `CRITICAL: You MUST only use node types from the following ALLOWED list: ${JSON.stringify(validNodeTypes.slice(0, 100))}... (and other standard n8n-nodes-base.* types). If a node type is not valid, replace it with 'n8n-nodes-base.httpRequest' or 'n8n-nodes-base.set'.` : ''}
|
|
275
|
+
IMPORTANT: If the error is "Unrecognized node type: n8n-nodes-base.schedule", you MUST fix it to "n8n-nodes-base.scheduleTrigger".
|
|
276
|
+
If a node produced 0 items, check its input data mapping or filter conditions.
|
|
277
|
+
If a node crashed, check missing parameters.
|
|
278
|
+
|
|
279
|
+
Output ONLY valid JSON. No markdown. RETURN THE ENTIRE FIXED WORKFLOW JSON.
|
|
280
|
+
`;
|
|
281
|
+
const response = await this.generateContent(prompt, { model });
|
|
282
|
+
try {
|
|
283
|
+
let cleanJson = response || '{}';
|
|
284
|
+
cleanJson = cleanJson.replace(/```json\n?|\n?```/g, '').trim();
|
|
285
|
+
const fixed = JSON.parse(cleanJson);
|
|
286
|
+
const invalidNodeMatch = errorContext.match(/Unrecognized node type: ([\w.-]+)/);
|
|
287
|
+
const explicitlyInvalid = invalidNodeMatch ? [invalidNodeMatch[1]] : [];
|
|
288
|
+
const corrected = this.fixHallucinatedNodes(fixed);
|
|
289
|
+
return this.validateAndShim(corrected, validNodeTypes, explicitlyInvalid);
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
console.error('Failed to parse AI workflow fix', e);
|
|
293
|
+
throw new Error('AI generated invalid JSON for fix');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Auto-correct common n8n node type hallucinations
|
|
298
|
+
*/
|
|
299
|
+
fixHallucinatedNodes(workflow) {
|
|
300
|
+
if (!workflow.nodes || !Array.isArray(workflow.nodes))
|
|
301
|
+
return workflow;
|
|
302
|
+
const corrections = {
|
|
303
|
+
'n8n-nodes-base.rssFeed': 'n8n-nodes-base.rssFeedRead',
|
|
304
|
+
'rssFeed': 'n8n-nodes-base.rssFeedRead',
|
|
305
|
+
'n8n-nodes-base.gpt': 'n8n-nodes-base.openAi',
|
|
306
|
+
'n8n-nodes-base.openai': 'n8n-nodes-base.openAi',
|
|
307
|
+
'openai': 'n8n-nodes-base.openAi',
|
|
308
|
+
'n8n-nodes-base.openAiChat': 'n8n-nodes-base.openAi',
|
|
309
|
+
'n8n-nodes-base.openAIChat': 'n8n-nodes-base.openAi',
|
|
310
|
+
'n8n-nodes-base.openaiChat': 'n8n-nodes-base.openAi',
|
|
311
|
+
'n8n-nodes-base.gemini': 'n8n-nodes-base.googleGemini',
|
|
312
|
+
'n8n-nodes-base.cheerioHtml': 'n8n-nodes-base.htmlExtract',
|
|
313
|
+
'cheerioHtml': 'n8n-nodes-base.htmlExtract',
|
|
314
|
+
'n8n-nodes-base.schedule': 'n8n-nodes-base.scheduleTrigger',
|
|
315
|
+
'schedule': 'n8n-nodes-base.scheduleTrigger',
|
|
316
|
+
'n8n-nodes-base.cron': 'n8n-nodes-base.scheduleTrigger',
|
|
317
|
+
'n8n-nodes-base.googleCustomSearch': 'n8n-nodes-base.googleGemini',
|
|
318
|
+
'googleCustomSearch': 'n8n-nodes-base.googleGemini',
|
|
319
|
+
};
|
|
320
|
+
workflow.nodes = workflow.nodes.map((node) => {
|
|
321
|
+
if (node.type && corrections[node.type]) {
|
|
322
|
+
console.log(`[AI Fix] Correcting node type: ${node.type} -> ${corrections[node.type]}`);
|
|
323
|
+
node.type = corrections[node.type];
|
|
324
|
+
}
|
|
325
|
+
if (node.type && !node.type.startsWith('n8n-nodes-base.') && !node.type.includes('.')) {
|
|
326
|
+
node.type = `n8n-nodes-base.${node.type}`;
|
|
327
|
+
}
|
|
328
|
+
return node;
|
|
329
|
+
});
|
|
330
|
+
return this.fixN8nConnections(workflow);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Force-fix connection structure to prevent "object is not iterable" errors
|
|
334
|
+
*/
|
|
335
|
+
fixN8nConnections(workflow) {
|
|
336
|
+
if (!workflow.connections || typeof workflow.connections !== 'object')
|
|
337
|
+
return workflow;
|
|
338
|
+
const fixedConnections = {};
|
|
339
|
+
for (const [sourceNode, targets] of Object.entries(workflow.connections)) {
|
|
340
|
+
if (!targets || typeof targets !== 'object')
|
|
341
|
+
continue;
|
|
342
|
+
const targetObj = targets;
|
|
343
|
+
if (targetObj.main) {
|
|
344
|
+
let mainArr = targetObj.main;
|
|
345
|
+
if (!Array.isArray(mainArr))
|
|
346
|
+
mainArr = [[{ node: String(mainArr), type: 'main', index: 0 }]];
|
|
347
|
+
const fixedMain = mainArr.map((segment) => {
|
|
348
|
+
if (!segment)
|
|
349
|
+
return [];
|
|
350
|
+
if (!Array.isArray(segment))
|
|
351
|
+
return [segment];
|
|
352
|
+
return segment.map((conn) => {
|
|
353
|
+
if (!conn)
|
|
354
|
+
return { node: 'Unknown', type: 'main', index: 0 };
|
|
355
|
+
if (typeof conn === 'string')
|
|
356
|
+
return { node: conn, type: 'main', index: 0 };
|
|
357
|
+
return {
|
|
358
|
+
node: String(conn.node || 'Unknown'),
|
|
359
|
+
type: conn.type || 'main',
|
|
360
|
+
index: conn.index || 0,
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
fixedConnections[sourceNode] = { main: fixedMain };
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
fixedConnections[sourceNode] = targetObj;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
workflow.connections = fixedConnections;
|
|
371
|
+
return workflow;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Validate against real node types and shim unknown ones
|
|
375
|
+
*/
|
|
376
|
+
validateAndShim(workflow, validNodeTypes = [], explicitlyInvalid = []) {
|
|
377
|
+
const valid = validNodeTypes || [];
|
|
378
|
+
const invalid = explicitlyInvalid || [];
|
|
379
|
+
if (valid.length === 0 && invalid.length === 0)
|
|
380
|
+
return workflow;
|
|
381
|
+
if (!workflow || !workflow.nodes || !Array.isArray(workflow.nodes))
|
|
382
|
+
return workflow;
|
|
383
|
+
const validSet = new Set(valid.map(t => t.toLowerCase()));
|
|
384
|
+
const invalidSet = new Set(invalid.map(t => t.toLowerCase()));
|
|
385
|
+
const isTrigger = (name) => name.toLowerCase().includes('trigger') || name.toLowerCase().includes('webhook');
|
|
386
|
+
workflow.nodes = workflow.nodes.map((node) => {
|
|
387
|
+
if (!node || !node.type)
|
|
388
|
+
return node;
|
|
389
|
+
const type = node.type.toLowerCase();
|
|
390
|
+
const shouldShim = invalidSet.has(type) || (validSet.size > 0 && !validSet.has(type));
|
|
391
|
+
if (!shouldShim)
|
|
392
|
+
return node;
|
|
393
|
+
console.warn(`[Validation] Unknown/Invalid node type detected: ${node.type}. Shimming...`);
|
|
394
|
+
const originalType = node.type;
|
|
395
|
+
const notes = `[Shim] Original Type: ${originalType}. Replaced because type is not installed on this n8n instance.`;
|
|
396
|
+
const apiKeywords = [
|
|
397
|
+
'api', 'http', 'slack', 'discord', 'telegram', 'google', 'aws',
|
|
398
|
+
'github', 'stripe', 'twilio', 'linear', 'notion', 'airtable',
|
|
399
|
+
'alpaca', 'openai', 'hubspot', 'mailchimp', 'postgres', 'mysql',
|
|
400
|
+
'redis', 'mongo', 'firebase', 'supabase',
|
|
401
|
+
];
|
|
402
|
+
const isApi = apiKeywords.some(keyword => originalType.includes(keyword));
|
|
403
|
+
let replacementType = 'n8n-nodes-base.set';
|
|
404
|
+
if (isTrigger(originalType)) {
|
|
405
|
+
replacementType = 'n8n-nodes-base.webhook';
|
|
406
|
+
}
|
|
407
|
+
else if (isApi) {
|
|
408
|
+
replacementType = 'n8n-nodes-base.httpRequest';
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
...node,
|
|
412
|
+
type: replacementType,
|
|
413
|
+
typeVersion: 1,
|
|
414
|
+
notes,
|
|
415
|
+
credentials: {},
|
|
416
|
+
parameters: { options: {} },
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
return workflow;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { WorkflowExecutionResult } from '../utils/n8nClient.js';
|
|
2
|
+
export declare class N8nService {
|
|
3
|
+
private static instance;
|
|
4
|
+
private baseUrl;
|
|
5
|
+
private apiKey;
|
|
6
|
+
private constructor();
|
|
7
|
+
static getInstance(): N8nService;
|
|
8
|
+
/**
|
|
9
|
+
* Deploy a workflow to n8n
|
|
10
|
+
*/
|
|
11
|
+
deployWorkflow(workflow: any, activate?: boolean): Promise<any>;
|
|
12
|
+
private request;
|
|
13
|
+
/**
|
|
14
|
+
* Execute a workflow
|
|
15
|
+
*/
|
|
16
|
+
executeWorkflow(workflowId: string): Promise<WorkflowExecutionResult>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export class N8nService {
|
|
2
|
+
static instance;
|
|
3
|
+
baseUrl;
|
|
4
|
+
apiKey;
|
|
5
|
+
constructor() {
|
|
6
|
+
this.baseUrl = process.env.N8N_API_URL || 'http://localhost:5678/api/v1';
|
|
7
|
+
this.apiKey = process.env.N8N_API_KEY || '';
|
|
8
|
+
if (!this.apiKey) {
|
|
9
|
+
console.warn('⚠️ N8N_API_KEY is not set. API calls will likely fail.');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
static getInstance() {
|
|
13
|
+
if (!N8nService.instance) {
|
|
14
|
+
N8nService.instance = new N8nService();
|
|
15
|
+
}
|
|
16
|
+
return N8nService.instance;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Deploy a workflow to n8n
|
|
20
|
+
*/
|
|
21
|
+
async deployWorkflow(workflow, activate = false) {
|
|
22
|
+
const payload = {
|
|
23
|
+
name: workflow.name || `My Workflow ${new Date().toISOString()}`,
|
|
24
|
+
nodes: workflow.nodes,
|
|
25
|
+
connections: workflow.connections,
|
|
26
|
+
settings: workflow.settings || {},
|
|
27
|
+
};
|
|
28
|
+
const response = await this.request('/workflows', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: JSON.stringify(payload)
|
|
31
|
+
});
|
|
32
|
+
if (activate && response.id) {
|
|
33
|
+
await this.request(`/workflows/${response.id}/activate`, { method: 'POST' });
|
|
34
|
+
response.active = true;
|
|
35
|
+
}
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
// Helper request method update
|
|
39
|
+
async request(endpoint, options = {}) {
|
|
40
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
41
|
+
const headers = {
|
|
42
|
+
'X-N8N-API-KEY': this.apiKey,
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
...options.headers,
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(url, { ...options, headers });
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
const errorBody = await response.text();
|
|
50
|
+
console.error(`n8n Error Body: ${errorBody}`);
|
|
51
|
+
throw new Error(`n8n API Error: ${response.status} ${response.statusText} - ${errorBody}`);
|
|
52
|
+
}
|
|
53
|
+
const text = await response.text();
|
|
54
|
+
return text ? JSON.parse(text) : {};
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(`Request failed: ${url}`, error);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Execute a workflow
|
|
63
|
+
*/
|
|
64
|
+
async executeWorkflow(workflowId) {
|
|
65
|
+
await this.request(`/workflows/${workflowId}/activate`, {
|
|
66
|
+
method: 'POST'
|
|
67
|
+
});
|
|
68
|
+
// Note: Actual execution trigger might depend on webhook or getting a test webhook URL
|
|
69
|
+
// For MVP, this might just toggle activation or assume it's a manual trigger
|
|
70
|
+
// Better: POST /workflows/:id/execute (if available in public API?)
|
|
71
|
+
// Public API actually supports `/workflows/:id/execute` in recent versions or via webhook
|
|
72
|
+
// Fallback Mock for now until we confirm endpoint availability or specific execution path
|
|
73
|
+
// Real implementation would POST to webhook or use executions API if supported
|
|
74
|
+
return {
|
|
75
|
+
executionId: 'mock-execution-' + Date.now(),
|
|
76
|
+
finished: true,
|
|
77
|
+
success: true,
|
|
78
|
+
data: { message: "Workflow executed (mock)" }
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface ReducedNodeDefinition {
|
|
2
|
+
name: string;
|
|
3
|
+
displayName: string;
|
|
4
|
+
description: string;
|
|
5
|
+
properties: any[];
|
|
6
|
+
}
|
|
7
|
+
export declare class NodeDefinitionsService {
|
|
8
|
+
private static instance;
|
|
9
|
+
private definitions;
|
|
10
|
+
private client;
|
|
11
|
+
private constructor();
|
|
12
|
+
static getInstance(): NodeDefinitionsService;
|
|
13
|
+
/**
|
|
14
|
+
* Load definitions from n8n instance.
|
|
15
|
+
* In a real app, we might cache this to a file.
|
|
16
|
+
*/
|
|
17
|
+
loadDefinitions(): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Search for nodes relevant to the query.
|
|
20
|
+
* Simple keyword matching for now.
|
|
21
|
+
*/
|
|
22
|
+
search(query: string, limit?: number): ReducedNodeDefinition[];
|
|
23
|
+
/**
|
|
24
|
+
* Get exact definitions for specific node types
|
|
25
|
+
*/
|
|
26
|
+
getDefinitions(nodeNames: string[]): ReducedNodeDefinition[];
|
|
27
|
+
/**
|
|
28
|
+
* Compress the definition to save tokens.
|
|
29
|
+
* We keep properties (parameters) but strip UI metadata.
|
|
30
|
+
*/
|
|
31
|
+
private reduceDefinition;
|
|
32
|
+
/**
|
|
33
|
+
* Format definitions for LLM System Prompt
|
|
34
|
+
*/
|
|
35
|
+
formatForLLM(definitions: ReducedNodeDefinition[]): string;
|
|
36
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { N8nClient } from '../utils/n8nClient.js';
|
|
2
|
+
import { ConfigManager } from '../utils/config.js';
|
|
3
|
+
export class NodeDefinitionsService {
|
|
4
|
+
static instance;
|
|
5
|
+
definitions = [];
|
|
6
|
+
client;
|
|
7
|
+
constructor() {
|
|
8
|
+
const n8nUrl = process.env.N8N_API_URL;
|
|
9
|
+
const n8nKey = process.env.N8N_API_KEY;
|
|
10
|
+
this.client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
11
|
+
}
|
|
12
|
+
static getInstance() {
|
|
13
|
+
if (!NodeDefinitionsService.instance) {
|
|
14
|
+
NodeDefinitionsService.instance = new NodeDefinitionsService();
|
|
15
|
+
}
|
|
16
|
+
return NodeDefinitionsService.instance;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Load definitions from n8n instance.
|
|
20
|
+
* In a real app, we might cache this to a file.
|
|
21
|
+
*/
|
|
22
|
+
async loadDefinitions() {
|
|
23
|
+
if (this.definitions.length > 0)
|
|
24
|
+
return;
|
|
25
|
+
console.log('Loading node definitions...');
|
|
26
|
+
try {
|
|
27
|
+
// Re-initialize client if env vars changed (e.g. after config load)
|
|
28
|
+
const config = await ConfigManager.load();
|
|
29
|
+
if (config.n8nUrl && config.n8nKey) {
|
|
30
|
+
this.client = new N8nClient({ apiUrl: config.n8nUrl, apiKey: config.n8nKey });
|
|
31
|
+
}
|
|
32
|
+
this.definitions = await this.client.getNodeTypes();
|
|
33
|
+
console.log(`Loaded ${this.definitions.length} node definitions.`);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error("Failed to load node definitions:", error);
|
|
37
|
+
// Fallback to empty to allow process to continue without RAG
|
|
38
|
+
this.definitions = [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Search for nodes relevant to the query.
|
|
43
|
+
* Simple keyword matching for now.
|
|
44
|
+
*/
|
|
45
|
+
search(query, limit = 5) {
|
|
46
|
+
const lowerQuery = query.toLowerCase();
|
|
47
|
+
const terms = lowerQuery.split(/\s+/).filter(t => t.length > 2);
|
|
48
|
+
if (terms.length === 0)
|
|
49
|
+
return [];
|
|
50
|
+
const matches = this.definitions.filter(def => {
|
|
51
|
+
const text = `${def.displayName} ${def.name} ${def.description || ''}`.toLowerCase();
|
|
52
|
+
return terms.some(term => text.includes(term));
|
|
53
|
+
});
|
|
54
|
+
// Sort by relevance (number of matched terms) - simplified
|
|
55
|
+
return matches.slice(0, limit).map(this.reduceDefinition);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get exact definitions for specific node types
|
|
59
|
+
*/
|
|
60
|
+
getDefinitions(nodeNames) {
|
|
61
|
+
return this.definitions
|
|
62
|
+
.filter(def => nodeNames.includes(def.name))
|
|
63
|
+
.map(this.reduceDefinition);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Compress the definition to save tokens.
|
|
67
|
+
* We keep properties (parameters) but strip UI metadata.
|
|
68
|
+
*/
|
|
69
|
+
reduceDefinition(def) {
|
|
70
|
+
return {
|
|
71
|
+
name: def.name,
|
|
72
|
+
displayName: def.displayName,
|
|
73
|
+
description: def.description,
|
|
74
|
+
// We need to carefully select properties.
|
|
75
|
+
// n8n properties are complex. We want 'name', 'type', 'default', 'description', 'options'.
|
|
76
|
+
properties: (def.properties || []).map((p) => ({
|
|
77
|
+
name: p.name,
|
|
78
|
+
displayName: p.displayName,
|
|
79
|
+
type: p.type,
|
|
80
|
+
default: p.default,
|
|
81
|
+
description: p.description,
|
|
82
|
+
// For 'options' type (dropdowns), include options
|
|
83
|
+
options: p.options ? p.options.map((o) => ({ name: o.name, value: o.value })) : undefined,
|
|
84
|
+
// For 'collection' or 'fixedCollection', we need substructure.
|
|
85
|
+
// This is a simplification. A full schema dump might be too large.
|
|
86
|
+
// Let's include 'typeOptions' as it often contains routing/validation info
|
|
87
|
+
typeOptions: p.typeOptions
|
|
88
|
+
}))
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Format definitions for LLM System Prompt
|
|
93
|
+
*/
|
|
94
|
+
formatForLLM(definitions) {
|
|
95
|
+
return definitions.map(def => `
|
|
96
|
+
Node: ${def.displayName} (${def.name})
|
|
97
|
+
Description: ${def.description}
|
|
98
|
+
Parameters:
|
|
99
|
+
${JSON.stringify(def.properties, null, 2)}
|
|
100
|
+
`).join('\n---\n');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface N8mConfig {
|
|
2
|
+
n8nUrl?: string;
|
|
3
|
+
n8nKey?: string;
|
|
4
|
+
aiKey?: string;
|
|
5
|
+
aiProvider?: string;
|
|
6
|
+
aiModel?: string;
|
|
7
|
+
aiBaseUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class ConfigManager {
|
|
10
|
+
private static configDir;
|
|
11
|
+
private static configFile;
|
|
12
|
+
static load(): Promise<N8mConfig>;
|
|
13
|
+
static save(config: Partial<N8mConfig>): Promise<void>;
|
|
14
|
+
static clear(): Promise<void>;
|
|
15
|
+
}
|