mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(ai-builder): Multi-agent workflow builder improvements, best practices and better template usage (#23417)
This commit is contained in:
+13
@@ -56,6 +56,19 @@ Valid Connection Methods:
|
||||
- Tools connected to agents via [ai_tool] - provides capabilities to agents
|
||||
- Vector stores use [ai_embedding] and [ai_document] - for AI-powered data retrieval
|
||||
|
||||
**CRITICAL: AI sub-nodes are the SOURCE of ai_* connections, NOT the target.**
|
||||
- Document Loader connects TO Vector Store (Document Loader is source, Vector Store is target)
|
||||
- Embeddings connects TO Vector Store (Embeddings is source, Vector Store is target)
|
||||
- Chat Model connects TO AI Agent (Chat Model is source, AI Agent is target)
|
||||
|
||||
In the connections JSON, this appears as:
|
||||
\`\`\`json
|
||||
"Document Loader": { "ai_document": [[{ "node": "Vector Store", ... }]] }
|
||||
\`\`\`
|
||||
This means the connection EXISTS - Document Loader provides ai_document capability TO Vector Store.
|
||||
|
||||
**NEVER flag "Vector Store missing Document Loader" if Document Loader has ai_document → Vector Store.**
|
||||
|
||||
3. **Shared Memory**: Multiple agents/workflows sharing the same memory node for context/data persistence
|
||||
- Same Window Buffer Memory connected to both a scheduled agent AND a chat agent
|
||||
- Both agents can access shared conversation history and context
|
||||
|
||||
+94
-10
@@ -22,6 +22,37 @@ export type ConnectionsResult = z.infer<typeof connectionsResultSchema>;
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on NODE CONNECTIONS and DATA FLOW.
|
||||
Your task is to verify that every connection follows n8n's sourcing rules, supports the requested behaviour, and respects hybrid AI patterns.
|
||||
|
||||
## Validation Instructions
|
||||
|
||||
Before providing your validation report, conduct your analysis in <analysis> tags where you systematically work through the following steps. It's OK for this section to be quite long.
|
||||
|
||||
1. **Enumerate all nodes and connections**:
|
||||
- List each node in the workflow with format: "Node Name (Node Type)"
|
||||
- For each node, list ALL its connections in format: "Source Node → Target Node [connection_type]"
|
||||
- Be exhaustive - write down every single connection you can identify
|
||||
|
||||
2. **Identify capability-only nodes**:
|
||||
- List which nodes are capability-only (Document Loader, Text Splitter, Embeddings)
|
||||
- For EACH capability-only node, explicitly state: "Does [Node Name] have any main connections? [Yes/No]"
|
||||
- If Yes, this is a potential violation (unless it's a false positive pattern)
|
||||
|
||||
3. **Validate connection patterns systematically**:
|
||||
For each of these expected patterns, write it out and check if it exists:
|
||||
- Expected: "Document Loader → Vector Store [ai_document]" (if RAG workflow)
|
||||
- Expected: "Language Model → AI Agent [ai_languageModel]" (if AI Agent exists)
|
||||
- Expected: "Tool → AI Agent [ai_tool]" (if AI Agent with tools)
|
||||
- Expected: "Set → Filter [main]" (standard output of a node)
|
||||
|
||||
4. **Check against false positive patterns**:
|
||||
- List the "Patterns That Are ALWAYS CORRECT" from above
|
||||
- For each pattern you observe in the workflow, explicitly check: "Is this one of the always-correct patterns? [Yes/No]"
|
||||
- If Yes, do NOT flag it as a violation
|
||||
|
||||
5. **Compile violations**:
|
||||
- Based on your systematic checks above, list any actual violations
|
||||
- For each potential violation, verify it's not in the false positive list
|
||||
- Provide clear reasoning for why each violation is a problem
|
||||
|
||||
## Connection Model Overview
|
||||
|
||||
### 1. Main Workflow Connections
|
||||
@@ -49,16 +80,69 @@ Your task is to verify that every connection follows n8n's sourcing rules, suppo
|
||||
- Memory/tool nodes often connect to multiple parents (e.g., memory -> agent and chat trigger) — this is valid
|
||||
- Vector stores can exist in multiple modes (insert vs. retrieve-as-tool). Having separate nodes for each mode is normal and not automatically duplication
|
||||
|
||||
## Patterns to Validate
|
||||
## Understanding n8n RAG Architecture
|
||||
|
||||
### Retrieval-Augmented Generation (RAG)
|
||||
- Data source or metadata prep (HTTP Request, Set, etc.) → Vector Store **via main** when mode is insert/upsert
|
||||
- Token Splitter → Document Loader [ai_textSplitter]
|
||||
- Document Loader → Vector Store [ai_document]
|
||||
- Embeddings → Vector Store [ai_embedding]
|
||||
- Vector Store (tool mode) → AI Agent [ai_tool]
|
||||
- **Do NOT expect Document Loader, Token Splitter, or Embeddings to have main inputs.** Their ai_* links are the correct pattern
|
||||
- Only flag the Vector Store if it genuinely lacks a main input when in insert/upsert mode
|
||||
Before validating, you must understand the critical architectural principle: some nodes in n8n are "capability providers" rather than data processors in the main flow.
|
||||
|
||||
### Document Loader: A Capability-Only Node
|
||||
|
||||
The Document Loader is the most important example:
|
||||
- Document Loader has NO main input and NO main output
|
||||
- It NEVER receives data via main connections
|
||||
- It connects TO Vector Store via the \`ai_document\` connection type
|
||||
- It reads data from workflow context (binary files, JSON) based on its configuration
|
||||
- It is a capability provider, not a data processor
|
||||
|
||||
**This means Document Loader will appear "disconnected" from the main data flow - THIS IS CORRECT BY DESIGN.**
|
||||
|
||||
### Correct RAG Pipeline Pattern
|
||||
|
||||
\`\`\`
|
||||
Data Source (Extract From File, HTTP Request, etc.)
|
||||
│
|
||||
│ [main]
|
||||
▼
|
||||
Vector Store (insert mode) ◄──[ai_document]── Document Loader ◄──[ai_textSplitter]── Text Splitter
|
||||
▲
|
||||
│
|
||||
└──[ai_embedding]── Embeddings
|
||||
\`\`\`
|
||||
|
||||
**How it works:**
|
||||
1. Data source connects to Vector Store via \`main\` connection - this triggers the insert operation
|
||||
2. Document Loader connects TO Vector Store via \`ai_document\` - provides document processing capability
|
||||
3. Text Splitter connects TO Document Loader via \`ai_textSplitter\` - provides chunking capability
|
||||
4. Embeddings connects TO Vector Store via \`ai_embedding\` - provides vectorization capability
|
||||
|
||||
### Patterns That Are ALWAYS CORRECT (Never Flag These)
|
||||
|
||||
- Document Loader has NO main connections (no main input, no main output)
|
||||
- Document Loader → Vector Store via \`ai_document\` connection
|
||||
- Text Splitter → Document Loader via \`ai_textSplitter\` connection
|
||||
- Extract From File/PDF/CSV → Vector Store via \`main\` connection
|
||||
- Data flows: Extract → Vector Store (main) while Document Loader → Vector Store (ai_document)
|
||||
- Document Loader appears "disconnected" from the main workflow path
|
||||
- Vector Store (tool mode) → AI Agent via \`ai_tool\` connection
|
||||
|
||||
### Connection Direction Rules
|
||||
|
||||
Memorize these correct directions:
|
||||
- ✅ Text Splitter → Document Loader [ai_textSplitter] (Text Splitter is SOURCE, Document Loader is TARGET)
|
||||
- ✅ Document Loader → Vector Store [ai_document] (Document Loader is SOURCE, Vector Store is TARGET)
|
||||
- ✅ Embeddings → Vector Store [ai_embedding] (Embeddings is SOURCE, Vector Store is TARGET)
|
||||
- ❌ Document Loader → Text Splitter [any] (NEVER valid)
|
||||
- ❌ Vector Store → Document Loader [any] (NEVER valid)
|
||||
|
||||
### Invalid Violations (DO NOT Output These)
|
||||
|
||||
These are examples of INCORRECT analysis - never output violations like these:
|
||||
- "Document Loader is disconnected from main data flow" - WRONG, this is correct behavior
|
||||
- "Document Loader is completely disconnected" - WRONG, it connects via ai_document
|
||||
- "Extract From File bypasses Document Loader" - WRONG, main data SHOULD go directly to Vector Store
|
||||
- "Document Loader should receive the extracted data" - WRONG, Document Loader reads from workflow context
|
||||
- "Document Loader should receive extracted data via main connection" - COMPLETELY WRONG
|
||||
- "Text Splitter → Document Loader is reversed" - WRONG, this is the correct direction
|
||||
- Any violation about Document Loader needing main connections - ALWAYS WRONG
|
||||
|
||||
### Agent Ecosystem
|
||||
- Language Model (e.g. Anthropic Chat) → AI Agent [ai_languageModel]
|
||||
@@ -102,7 +186,7 @@ Your task is to verify that every connection follows n8n's sourcing rules, suppo
|
||||
- Branches that should merge but stay isolated without reason
|
||||
- Single-output Switch nodes where the unused branch is clearly needed for parity but left dangling
|
||||
|
||||
## Conditional Nodes (IF, Switch)
|
||||
## Conditional Nodes (IF, Switch, Filter)
|
||||
- They expose multiple outputs; expect a true/false or default branch
|
||||
- Default/fallback branches should either connect to a terminal node or rejoin the flow
|
||||
- Document when a branch is intentionally unused; otherwise apply a minor penalty
|
||||
|
||||
+92
-1
@@ -64,7 +64,98 @@ Evaluate ONLY the functional aspects - whether the workflow achieves the intende
|
||||
- Check if operations are in the correct logical sequence
|
||||
- Verify it handles all scenarios mentioned in the user prompt
|
||||
- Ensure data transformations are implemented as requested
|
||||
- Remember: functional correctness is about meeting requirements, not perfection`;
|
||||
- Remember: functional correctness is about meeting requirements, not perfection
|
||||
|
||||
## n8n RAG Pipeline Pattern (CRITICAL - Do Not Misunderstand)
|
||||
|
||||
**Document Loader is a CAPABILITY-ONLY sub-node. It NEVER receives main data flow.**
|
||||
|
||||
The Document Loader node:
|
||||
- Has NO main input - it cannot and should not receive data via main connections
|
||||
- ONLY connects via ai_document TO a Vector Store (Document Loader → Vector Store)
|
||||
- Reads data from the workflow context (binary files, JSON) based on its dataType configuration
|
||||
- Is a capability provider that tells Vector Store HOW to process documents
|
||||
|
||||
**CORRECT RAG Pipeline:**
|
||||
\`\`\`
|
||||
Data Source (Extract From File, HTTP Request, etc.)
|
||||
│
|
||||
│ [main]
|
||||
▼
|
||||
Vector Store (insert mode) ◄──[ai_document]── Document Loader ◄──[ai_textSplitter]── Text Splitter
|
||||
▲
|
||||
└──[ai_embedding]── Embeddings
|
||||
\`\`\`
|
||||
|
||||
**THE FOLLOWING ARE ALL CORRECT - NEVER FLAG AS VIOLATIONS:**
|
||||
- Document Loader has NO main connections - THIS IS CORRECT BY DESIGN
|
||||
- Document Loader connects TO Vector Store via ai_document - THIS IS THE ONLY WAY TO USE IT
|
||||
- Extract From File connects directly to Vector Store via main - THIS IS CORRECT
|
||||
- Document Loader appears "isolated" from the main data path - THIS IS CORRECT
|
||||
|
||||
**INVALID VIOLATION EXAMPLES - DO NOT OUTPUT THESE:**
|
||||
- ❌ "Document ingestion pipeline is broken because data bypasses Document Loader" - WRONG ANALYSIS
|
||||
- ❌ "Extract From File connects directly to Vector Store, bypassing Document Loader" - This IS the correct pattern
|
||||
- ❌ "Document Loader is disconnected from main data flow" - CORRECT behavior, not an error
|
||||
- ❌ "Document Loader needs to receive the extracted data" - WRONG, it reads from workflow context
|
||||
- ❌ "Document Loader is completely disconnected from the main data flow" - WRONG, it connects via ai_document
|
||||
- ❌ "Vector Store is missing required Document Loader connection via ai_document port" when Document Loader IS connected via ai_document - CHECK THE CONNECTIONS CAREFULLY
|
||||
- ❌ Any violation claiming Document Loader should receive main data - ALWAYS WRONG
|
||||
|
||||
The main connection triggers the Vector Store insert operation. The Document Loader provides document processing capability via ai_document. These work together but the Document Loader does NOT sit in the main data path.
|
||||
|
||||
## ABSOLUTE RULE: Embedding Nodes MUST Use Embedding Models (NEVER A VIOLATION)
|
||||
|
||||
**THIS IS THE MOST IMPORTANT RULE IN THIS EVALUATOR. READ CAREFULLY.**
|
||||
|
||||
### The Technical Reality
|
||||
- **Embedding models** (text-embedding-3-small, text-embedding-3-large, text-embedding-ada-002): Convert text to vectors
|
||||
- **Chat models** (gpt-4, gpt-4o, gpt-4o-mini, gpt-4.1-mini, claude-3, gemini-pro): Generate text responses
|
||||
- **Chat models CANNOT generate embeddings. It is technically impossible.**
|
||||
|
||||
### The Rule
|
||||
**When an embedding node uses an embedding model, it is ALWAYS CORRECT - regardless of what the user requested.**
|
||||
|
||||
If a user says "use gpt-4o-mini for embeddings" or "use gpt-4.1-mini for embeddings":
|
||||
- The user has made a technical error (requested an impossible configuration)
|
||||
- The workflow builder CORRECTLY uses an embedding model instead
|
||||
- This is the RIGHT behavior, NOT a violation
|
||||
- **DO NOT OUTPUT ANY VIOLATION FOR THIS SCENARIO**
|
||||
|
||||
### FORBIDDEN Violations - NEVER Output These
|
||||
You must NEVER output violations like:
|
||||
- ❌ "User requested gpt-4o-mini but workflow uses text-embedding-3-small"
|
||||
- ❌ "User explicitly requested embeddings using 'gpt-4.1-mini' but workflow uses 'text-embedding-3-small'"
|
||||
- ❌ "User's explicit specification was not followed" (when user specified a chat model for embeddings)
|
||||
- ❌ "The workflow does not use the model specified by the user" (for embedding nodes)
|
||||
- ❌ Any violation mentioning that an embedding node should use a chat model
|
||||
|
||||
### Why This Rule Exists
|
||||
This is like a user asking to "cut wood with a hammer" - using a saw instead is correct, not a violation. The workflow builder is HELPING the user by using the right tool for the job.
|
||||
|
||||
### Examples of CORRECT Behavior (Not Violations)
|
||||
- User says "gpt-4o-mini for embeddings" → Workflow uses text-embedding-3-small ✓ PERFECT
|
||||
- User says "gpt-4.1-mini for embeddings" → Workflow uses text-embedding-3-small ✓ PERFECT
|
||||
- User says "gpt-4 for vector store" → Workflow uses text-embedding-3-large ✓ PERFECT
|
||||
- User mentions ANY chat model for embedding tasks → Workflow uses ANY embedding model ✓ PERFECT
|
||||
|
||||
## Model Selection: ALWAYS Minor Severity at Most
|
||||
|
||||
**Model selection differences are NEVER critical or major violations.**
|
||||
|
||||
When evaluating model choices:
|
||||
1. **Embedding models in embedding nodes**: ALWAYS correct, even if user requested a chat model
|
||||
2. **Same family, different model**: Minor at most (e.g., user says gpt-4, workflow uses gpt-4o-mini)
|
||||
3. **Same provider, different model**: Minor at most (e.g., user says claude-3-opus, workflow uses claude-3-sonnet)
|
||||
4. **Different provider entirely**: Minor at most, unless user explicitly required a specific provider for a business reason
|
||||
|
||||
**Examples of CORRECT behavior (not violations):**
|
||||
- User requests "gpt-4o-mini" → Workflow uses "gpt-4o" or "gpt-4" ✓
|
||||
- User requests "claude" → Workflow uses any Anthropic model ✓
|
||||
- User requests "OpenAI" → Workflow uses any OpenAI model ✓
|
||||
- User mentions any model → Workflow uses a different but capable model ✓
|
||||
|
||||
**The workflow builder selects appropriate models. Model choice is a preference, not a functional requirement.**`;
|
||||
|
||||
const humanTemplate = `Evaluate the functional correctness of this workflow:
|
||||
|
||||
|
||||
+71
-2
@@ -22,6 +22,28 @@ export type NodeConfigurationResult = z.infer<typeof nodeConfigurationResultSche
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on NODE CONFIGURATION and PARAMETERS.
|
||||
Your task is to evaluate whether nodes are configured with correct parameters and settings.
|
||||
|
||||
## SCOPE: ONLY Evaluate Node Parameters
|
||||
|
||||
**YOUR SCOPE IS LIMITED TO:**
|
||||
- Node parameter values (the "parameters" object inside each node)
|
||||
- Whether parameter values match what the user requested
|
||||
- Whether required parameters are present
|
||||
- Whether parameter values are valid (correct types, valid JSON, etc.)
|
||||
|
||||
**DO NOT EVALUATE (these are handled by other evaluators):**
|
||||
- Node connections (handled by Connections Evaluator)
|
||||
- Whether nodes are connected to each other
|
||||
- Missing ai_document, ai_embedding, ai_tool, ai_memory, or any other connection types
|
||||
- Data flow between nodes
|
||||
|
||||
**NEVER OUTPUT VIOLATIONS ABOUT:**
|
||||
- ❌ "missing Document Loader connection"
|
||||
- ❌ "missing ai_document connection"
|
||||
- ❌ "missing ai_embedding connection"
|
||||
- ❌ "missing required connection"
|
||||
- ❌ Any violation mentioning "connection" - that's not your job
|
||||
|
||||
If you see something that looks like a connection issue, IGNORE IT. Focus only on the parameters object.
|
||||
|
||||
## CRITICAL: Understanding n8n Credentials and Configuration
|
||||
- **NEVER penalize nodes for missing credentials**
|
||||
@@ -46,12 +68,56 @@ Your task is to evaluate whether nodes are configured with correct parameters an
|
||||
- Format: \`{{ $fromAI('parameter', 'description') }}\` is correct and expected
|
||||
- DO NOT penalize $fromAI in TOOL NODE parameters
|
||||
|
||||
## ABSOLUTE RULE: Embedding Nodes MUST Use Embedding Models (NEVER A VIOLATION)
|
||||
|
||||
**THIS IS THE MOST IMPORTANT RULE IN THIS EVALUATOR. READ CAREFULLY.**
|
||||
|
||||
### The Technical Reality
|
||||
- **Embedding models** (text-embedding-3-small, text-embedding-3-large, text-embedding-ada-002): Convert text to vectors
|
||||
- **Chat models** (gpt-4, gpt-4o, gpt-4o-mini, gpt-4.1-mini, claude-3, etc.): Generate text
|
||||
- **Chat models CANNOT generate embeddings. It is technically impossible.**
|
||||
|
||||
### The Rule
|
||||
**When an embedding node uses an embedding model, it is ALWAYS CORRECT - regardless of what the user requested.**
|
||||
|
||||
If a user says "use gpt-4o-mini for embeddings" or "use gpt-4.1-mini for embeddings":
|
||||
- The user has made a technical error (requested an impossible configuration)
|
||||
- The workflow builder CORRECTLY uses an embedding model instead
|
||||
- This is the RIGHT behavior, NOT a violation
|
||||
- **DO NOT OUTPUT ANY VIOLATION FOR THIS SCENARIO**
|
||||
|
||||
### FORBIDDEN Violations - NEVER Output These
|
||||
You must NEVER output violations like:
|
||||
- ❌ "User requested gpt-4o-mini but workflow uses text-embedding-3-small"
|
||||
- ❌ "User explicitly requested embeddings using 'gpt-4.1-mini' but workflow uses 'text-embedding-3-small'"
|
||||
- ❌ "User's explicit specification was not followed" (when user specified a chat model for embeddings)
|
||||
- ❌ "Embedding node uses wrong model"
|
||||
- ❌ Any violation about embedding nodes not using chat models
|
||||
|
||||
### Examples of CORRECT Behavior (Not Violations)
|
||||
- User says "gpt-4o-mini for embeddings" → Workflow uses text-embedding-3-small ✓ PERFECT
|
||||
- User says "gpt-4.1-mini for embeddings" → Workflow uses text-embedding-3-small ✓ PERFECT
|
||||
- User mentions ANY chat model for embedding tasks → Workflow uses ANY embedding model ✓ PERFECT
|
||||
|
||||
## General Model Selection Rules
|
||||
|
||||
**Model selection differences are NEVER critical or major violations. At most MINOR.**
|
||||
|
||||
Model choices are preferences, not requirements:
|
||||
- Same provider, different model = MINOR at most (gpt-4 vs gpt-4o-mini)
|
||||
- Different provider = MINOR at most (OpenAI vs Anthropic)
|
||||
- Model selection is NEVER critical or major
|
||||
|
||||
**Examples of CORRECT behavior (not violations):**
|
||||
- User says "gpt-4" → Workflow uses gpt-4o-mini ✓
|
||||
- User says "claude" → Workflow uses any Anthropic model ✓
|
||||
- User mentions model X → Workflow uses capable model Y ✓
|
||||
|
||||
## Evaluation Criteria
|
||||
|
||||
### Check for these violations:
|
||||
|
||||
**Critical (-30 to -40 points):** ONLY for actual breaking issues:
|
||||
- User provided specific value that's incorrectly implemented
|
||||
- Truly required parameters completely absent (not empty/placeholder):
|
||||
- HTTP Request without URL (unless using $fromAI)
|
||||
- Database operations without operation type specified
|
||||
@@ -61,16 +127,19 @@ Your task is to evaluate whether nodes are configured with correct parameters an
|
||||
- Non-numeric values in number-only fields
|
||||
- Configuration that would cause runtime crash
|
||||
- **NEVER penalize for missing credentials or API keys**
|
||||
- **NEVER penalize for model selection choices**
|
||||
|
||||
**Major (-10 to -20 points):**
|
||||
- Wrong operation mode when explicitly specified by user
|
||||
- Significant deviation from requested behavior
|
||||
- Significant deviation from requested behavior (NOT model choices)
|
||||
- Missing resource/operation selection that prevents node from functioning
|
||||
- **NOT model selection - model differences are minor at most**
|
||||
|
||||
**Minor (-2 to -5 points):**
|
||||
- Suboptimal but working configurations
|
||||
- Style preferences or minor inefficiencies
|
||||
- Missing optional parameters that could improve functionality
|
||||
- Model selection differences (if any - usually not worth flagging)
|
||||
|
||||
## Context-Aware Evaluation
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ export function generateMarkdownReport(
|
||||
- Connections: ${formatPercentage(categoryAverages.connections)}
|
||||
- Expressions: ${formatPercentage(categoryAverages.expressions)}
|
||||
- Node Configuration: ${formatPercentage(categoryAverages.nodeConfiguration)}
|
||||
- Efficiency: ${formatPercentage(categoryAverages.efficiency ?? 0)}
|
||||
- Data Flow: ${formatPercentage(categoryAverages.dataFlow ?? 0)}
|
||||
- Maintainability: ${formatPercentage(categoryAverages.maintainability ?? 0)}
|
||||
- Best Practices: ${formatPercentage(categoryAverages.bestPractices ?? 0)}
|
||||
|
||||
## Violations Summary
|
||||
@@ -126,6 +129,18 @@ export function generateMarkdownReport(
|
||||
...v,
|
||||
category: 'Node Configuration',
|
||||
})),
|
||||
...result.evaluationResult.efficiency.violations.map((v) => ({
|
||||
...v,
|
||||
category: 'Efficiency',
|
||||
})),
|
||||
...result.evaluationResult.dataFlow.violations.map((v) => ({
|
||||
...v,
|
||||
category: 'Data Flow',
|
||||
})),
|
||||
...result.evaluationResult.maintainability.violations.map((v) => ({
|
||||
...v,
|
||||
category: 'Maintainability',
|
||||
})),
|
||||
...result.evaluationResult.bestPractices.violations.map((v) => ({
|
||||
...v,
|
||||
category: 'Best Practices',
|
||||
@@ -239,6 +254,9 @@ export function displaySummaryTable(metrics: {
|
||||
[' Connections', formatColoredScore(categoryAverages.connections)],
|
||||
[' Expressions', formatColoredScore(categoryAverages.expressions)],
|
||||
[' Node Config', formatColoredScore(categoryAverages.nodeConfiguration)],
|
||||
[' Efficiency', formatColoredScore(categoryAverages.efficiency ?? 0)],
|
||||
[' Data Flow', formatColoredScore(categoryAverages.dataFlow ?? 0)],
|
||||
[' Maintainability', formatColoredScore(categoryAverages.maintainability ?? 0)],
|
||||
[' Best Practices', formatColoredScore(categoryAverages.bestPractices ?? 0)],
|
||||
[' Violations', ''],
|
||||
[
|
||||
@@ -387,6 +405,21 @@ export function displayViolationsDetail(results: TestResult[]): void {
|
||||
testName: result.testCase.name,
|
||||
source: 'llm' as const,
|
||||
})),
|
||||
...result.evaluationResult.efficiency.violations.map((violation: Violation) => ({
|
||||
violation: { ...violation, category: 'Efficiency' },
|
||||
testName: result.testCase.name,
|
||||
source: 'llm' as const,
|
||||
})),
|
||||
...result.evaluationResult.dataFlow.violations.map((violation: Violation) => ({
|
||||
violation: { ...violation, category: 'Data Flow' },
|
||||
testName: result.testCase.name,
|
||||
source: 'llm' as const,
|
||||
})),
|
||||
...result.evaluationResult.maintainability.violations.map((violation: Violation) => ({
|
||||
violation: { ...violation, category: 'Maintainability' },
|
||||
testName: result.testCase.name,
|
||||
source: 'llm' as const,
|
||||
})),
|
||||
...result.evaluationResult.bestPractices.violations.map((v) => ({
|
||||
violation: { ...v, category: 'Best Practices' },
|
||||
testName: result.testCase.name,
|
||||
|
||||
@@ -5,10 +5,13 @@ import { jsonParse } from 'n8n-workflow';
|
||||
import { basename, dirname, join } from 'path';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { mermaidStringify, type MermaidOptions } from '@/tools/utils/markdown-workflow.utils';
|
||||
import { mermaidStringify, type MermaidOptions } from '@/tools/utils/mermaid.utils';
|
||||
import type { WorkflowMetadata } from '@/types';
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
// exported workflows (unlike templates) don't have a template ID - but the script doesn't need them to have it
|
||||
const templateId = 0;
|
||||
|
||||
/**
|
||||
* Type guard to check if value is a direct workflow format (nodes and connections at root)
|
||||
*/
|
||||
@@ -135,6 +138,7 @@ function loadWorkflow(filePath: string): WorkflowMetadata {
|
||||
if (isDirectWorkflowFormat(json)) {
|
||||
const name = json.name ?? basename(filePath, '.json');
|
||||
return {
|
||||
templateId,
|
||||
name,
|
||||
workflow: {
|
||||
name,
|
||||
@@ -147,6 +151,7 @@ function loadWorkflow(filePath: string): WorkflowMetadata {
|
||||
if (isWorkflowMetadataFormat(json)) {
|
||||
const workflowName = json.workflow.name ?? basename(filePath, '.json');
|
||||
return {
|
||||
templateId,
|
||||
name: json.name ?? workflowName,
|
||||
workflow: {
|
||||
name: workflowName,
|
||||
|
||||
@@ -142,12 +142,18 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph
|
||||
logger,
|
||||
featureFlags,
|
||||
});
|
||||
const compiledBuilder = builderSubgraph.create({ parsedNodeTypes, llm: llmComplexTask, logger });
|
||||
const compiledBuilder = builderSubgraph.create({
|
||||
parsedNodeTypes,
|
||||
llm: llmComplexTask,
|
||||
logger,
|
||||
featureFlags,
|
||||
});
|
||||
const compiledConfigurator = configuratorSubgraph.create({
|
||||
parsedNodeTypes,
|
||||
llm: llmComplexTask,
|
||||
logger,
|
||||
instanceUrl,
|
||||
featureFlags,
|
||||
});
|
||||
|
||||
// Build graph using method chaining for proper TypeScript inference
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Annotation, messagesStateReducer } from '@langchain/langgraph';
|
||||
|
||||
import type { CoordinationLogEntry } from './types/coordination';
|
||||
import type { DiscoveryContext } from './types/discovery-types';
|
||||
import type { NodeConfigurationsMap } from './types/tools';
|
||||
import type { WorkflowMetadata } from './types/tools';
|
||||
import type { SimpleWorkflow, WorkflowOperation } from './types/workflow';
|
||||
import { appendArrayReducer, nodeConfigurationsReducer } from './utils/state-reducers';
|
||||
import { appendArrayReducer, cachedTemplatesReducer } from './utils/state-reducers';
|
||||
import type { ChatPayload } from './workflow-builder-agent';
|
||||
|
||||
/**
|
||||
@@ -68,10 +68,10 @@ export const ParentGraphState = Annotation.Root({
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Node configurations collected from workflow examples
|
||||
// Used to provide example parameter configurations when calling tools
|
||||
nodeConfigurations: Annotation<NodeConfigurationsMap>({
|
||||
reducer: nodeConfigurationsReducer,
|
||||
default: () => ({}),
|
||||
// Cached workflow templates from template API
|
||||
// Shared across subgraphs to reduce API calls
|
||||
cachedTemplates: Annotation<WorkflowMetadata[]>({
|
||||
reducer: cachedTemplatesReducer,
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -88,19 +88,36 @@ const STRUCTURED_OUTPUT_PARSER = `WHEN TO SET hasOutputParser: true on AI Agent:
|
||||
- AI output will be stored in database/data tables with specific fields
|
||||
- AI is classifying, scoring, or extracting specific data fields`;
|
||||
|
||||
/** AI sub-nodes are SOURCES (they "provide" capabilities), so arrows point FROM sub-node TO parent */
|
||||
const AI_CONNECTIONS = `n8n connections flow from SOURCE (output) to TARGET (input).
|
||||
|
||||
Regular data flow: Source node output → Target node input
|
||||
Example: HTTP Request → Set (HTTP Request is source, Set is target)
|
||||
Regular "main" connections flow: Source → Target (data flows forward)
|
||||
Example: HTTP Request → Set (HTTP outputs data, Set receives it)
|
||||
|
||||
AI sub-nodes PROVIDE capabilities, making them the SOURCE:
|
||||
AI CAPABILITY CONNECTIONS are REVERSED in direction:
|
||||
Sub-nodes (tools, memory, models) connect TO the AI Agent, NOT from it.
|
||||
The sub-node is the SOURCE, the AI Agent is the TARGET.
|
||||
|
||||
⚠️ WRONG: AI Agent → Calculator Tool (NEVER do this)
|
||||
✅ CORRECT: Calculator Tool → AI Agent (tool provides capability to agent)
|
||||
|
||||
When calling connect_nodes for AI sub-nodes:
|
||||
- sourceNodeName: The sub-node (tool, memory, model, parser)
|
||||
- targetNodeName: The AI Agent (or Vector Store, Document Loader)
|
||||
- connectionType: The appropriate ai_* type
|
||||
|
||||
AI Connection Examples (SOURCE → TARGET [connectionType]):
|
||||
- OpenAI Chat Model → AI Agent [ai_languageModel]
|
||||
- Calculator Tool → AI Agent [ai_tool]
|
||||
- HTTP Request Tool → AI Agent [ai_tool]
|
||||
- Window Buffer Memory → AI Agent [ai_memory]
|
||||
- Structured Output Parser → AI Agent [ai_outputParser]
|
||||
- Token Splitter → Default Data Loader [ai_textSplitter]
|
||||
- Default Data Loader → Vector Store [ai_document]
|
||||
- Embeddings OpenAI → Vector Store [ai_embedding]`;
|
||||
- Embeddings OpenAI → Vector Store [ai_embedding]
|
||||
- Vector Store (retrieve-as-tool mode) → AI Agent [ai_tool]
|
||||
|
||||
The AI Agent only has ONE "main" output for regular data flow.
|
||||
All inputs to the AI Agent come FROM sub-nodes via ai_* connection types.`;
|
||||
|
||||
const BRANCHING = `If two nodes (B and C) are both connected to the same output of a node (A), both will execute (with the same data). Whether B or C executes first is determined by their position on the canvas: the highest one executes first. Execution happens depth-first, i.e. any downstream nodes connected to the higher node will execute before the lower node is executed.
|
||||
Nodes that route the flow (e.g. if, switch) apply their conditions independently to each input item. They may route different items to different branches in the same execution.`;
|
||||
@@ -203,30 +220,84 @@ Example connectionParameters for 3-way routing:
|
||||
}}
|
||||
}}`;
|
||||
|
||||
const CONNECTION_TYPES = `**Main Connections** (regular data flow):
|
||||
const NODE_CONNECTION_EXAMPLES = `<node_connection_examples>
|
||||
When connecting nodes with non-standard output patterns, use get_node_connection_examples:
|
||||
|
||||
Call get_node_connection_examples when:
|
||||
- Connecting Loop Over Items (splitInBatches) - has TWO outputs with specific meanings
|
||||
- Connecting Switch nodes with multiple outputs
|
||||
- Connecting IF nodes with true/false branches
|
||||
- Any node where you're unsure about connection patterns
|
||||
|
||||
Usage:
|
||||
- nodeType: "n8n-nodes-base.splitInBatches" (exact node type)
|
||||
- Returns mermaid diagrams showing how the node is typically connected
|
||||
|
||||
CRITICAL for Loop Over Items (splitInBatches):
|
||||
This node has TWO outputs that work differently from most nodes:
|
||||
- Output 0 (first array element) = "Done" branch - connects to nodes that run AFTER all looping completes
|
||||
- Output 1 (second array element) = "Loop" branch - connects to nodes that process each batch during the loop
|
||||
This is COUNTERINTUITIVE - the loop processing is on output 1, NOT output 0.
|
||||
|
||||
When connecting splitInBatches, use sourceOutputIndex to specify which output:
|
||||
- sourceOutputIndex: 0 → "Done" branch (post-loop processing, aggregation)
|
||||
- sourceOutputIndex: 1 → "Loop" branch (batch processing during loop)
|
||||
|
||||
Example: Looping over items, processing each batch, then aggregating results:
|
||||
- connect_nodes(source: "Loop Over Items", target: "Process Each Batch", sourceOutputIndex: 1) // Loop branch
|
||||
- connect_nodes(source: "Loop Over Items", target: "Aggregate Results", sourceOutputIndex: 0) // Done branch
|
||||
- connect_nodes(source: "Process Each Batch", target: "Loop Over Items") // Loop back for next batch
|
||||
</node_connection_examples>`;
|
||||
|
||||
const CONNECTION_TYPES = `<connection_type_reference>
|
||||
CONNECTION TYPES AND DIRECTIONS:
|
||||
|
||||
**Main Connections** (main) - Regular data flow, source outputs TO target:
|
||||
- Trigger → HTTP Request → Set → Email
|
||||
- AI Agent → Email (AI Agent's main output goes to next node)
|
||||
|
||||
**AI Language Model Connections** (ai_languageModel):
|
||||
**AI Capability Connections** - Sub-nodes connect TO their parent node:
|
||||
Remember: Sub-node is SOURCE, Parent is TARGET
|
||||
|
||||
ai_languageModel - Language model provides LLM capability:
|
||||
- OpenAI Chat Model → AI Agent
|
||||
- Anthropic Chat Model → AI Agent
|
||||
|
||||
**AI Tool Connections** (ai_tool):
|
||||
ai_tool - Tool provides action capability:
|
||||
- Calculator Tool → AI Agent
|
||||
- AI Agent Tool → AI Agent (for multi-agent systems)
|
||||
- HTTP Request Tool → AI Agent
|
||||
- Code Tool → AI Agent
|
||||
- AI Agent Tool → AI Agent (multi-agent systems)
|
||||
|
||||
**AI Document Connections** (ai_document):
|
||||
- Document Loader → Vector Store
|
||||
|
||||
**AI Embedding Connections** (ai_embedding):
|
||||
- OpenAI Embeddings → Vector Store
|
||||
|
||||
**AI Text Splitter Connections** (ai_textSplitter):
|
||||
- Token Text Splitter → Document Loader
|
||||
|
||||
**AI Memory Connections** (ai_memory):
|
||||
ai_memory - Memory provides conversation history:
|
||||
- Window Buffer Memory → AI Agent
|
||||
- Postgres Chat Memory → AI Agent
|
||||
|
||||
**AI Vector Store in retrieve-as-tool mode** (ai_tool):
|
||||
- Vector Store → AI Agent`;
|
||||
ai_outputParser - Parser provides structured output capability:
|
||||
- Structured Output Parser → AI Agent
|
||||
|
||||
ai_document - Document loader provides documents:
|
||||
- Default Data Loader → Vector Store
|
||||
|
||||
ai_embedding - Embeddings provides vector generation:
|
||||
- OpenAI Embeddings → Vector Store
|
||||
- Cohere Embeddings → Vector Store
|
||||
|
||||
ai_textSplitter - Splitter provides chunking capability:
|
||||
- Token Text Splitter → Document Loader
|
||||
- Recursive Character Text Splitter → Document Loader
|
||||
|
||||
ai_vectorStore - Vector store provides retrieval (when used as tool):
|
||||
- Vector Store (mode: retrieve-as-tool) → AI Agent [ai_tool]
|
||||
|
||||
COMMON MISTAKES TO AVOID:
|
||||
❌ AI Agent → OpenAI Chat Model (WRONG - model provides TO agent)
|
||||
❌ AI Agent → Calculator Tool (WRONG - tool provides TO agent)
|
||||
❌ AI Agent → Window Buffer Memory (WRONG - memory provides TO agent)
|
||||
✅ OpenAI Chat Model → AI Agent (CORRECT)
|
||||
✅ Calculator Tool → AI Agent (CORRECT)
|
||||
✅ Window Buffer Memory → AI Agent (CORRECT)
|
||||
</connection_type_reference>`;
|
||||
|
||||
const RESTRICTIONS = `- Respond before calling validate_structure
|
||||
- Skip validation even if you think structure is correct
|
||||
@@ -258,6 +329,7 @@ export function buildBuilderPrompt(): string {
|
||||
.section('agent_node_distinction', AGENT_NODE_DISTINCTION)
|
||||
.section('rag_workflow_pattern', RAG_PATTERN)
|
||||
.section('switch_node_pattern', SWITCH_NODE_PATTERN)
|
||||
.section('node_connection_examples', NODE_CONNECTION_EXAMPLES)
|
||||
.section('connection_type_examples', CONNECTION_TYPES)
|
||||
.section('do_not', RESTRICTIONS)
|
||||
.section('response_format', RESPONSE_FORMAT)
|
||||
|
||||
@@ -12,18 +12,22 @@ const CONFIGURATOR_ROLE =
|
||||
|
||||
const EXECUTION_SEQUENCE = `You MUST follow these steps IN ORDER. Do not skip any step.
|
||||
|
||||
STEP 1: CONFIGURE ALL NODES
|
||||
STEP 1: RETRIEVE NODE EXAMPLES
|
||||
- Call the get_node_configuration_examples tool for each node type being configured
|
||||
- Use the examples to understand how these node types can be configured
|
||||
|
||||
STEP 2: CONFIGURE ALL NODES
|
||||
- Call update_node_parameters for EVERY node in the workflow
|
||||
- Configure multiple nodes in PARALLEL for efficiency
|
||||
- Do NOT respond with text - START CONFIGURING immediately
|
||||
|
||||
STEP 2: VALIDATE (REQUIRED)
|
||||
STEP 3: VALIDATE (REQUIRED)
|
||||
- After ALL configurations complete, call validate_configuration
|
||||
- This step is MANDATORY - you cannot finish without it
|
||||
- If validation finds issues, fix them and validate again
|
||||
- MAXIMUM 3 VALIDATION ATTEMPTS: After 3 calls to validate_configuration, proceed to respond regardless of remaining issues
|
||||
|
||||
STEP 3: RESPOND TO USER
|
||||
STEP 4: RESPOND TO USER
|
||||
- Only after validation passes, provide your response
|
||||
|
||||
NEVER respond to the user without calling validate_configuration first`;
|
||||
@@ -102,6 +106,20 @@ For numeric ranges (e.g., $100-$1000):
|
||||
|
||||
Always set renameOutput: true and provide descriptive outputKey labels.`;
|
||||
|
||||
const NODE_CONFIGURATION_EXAMPLES = `NODE CONFIGURATION EXAMPLES:
|
||||
When configuring complex nodes, use get_node_configuration_examples to see real-world examples from community templates:
|
||||
|
||||
When to use:
|
||||
- Before configuring nodes with complex parameters (HTTP Request, Code, IF, Switch)
|
||||
- When you need to understand proper parameter structure for unfamiliar nodes
|
||||
- When user requests a specific integration pattern
|
||||
|
||||
Usage:
|
||||
- Call with nodeType: "n8n-nodes-base.httpRequest" (exact node type name)
|
||||
- Optionally filter by nodeVersion if needed
|
||||
- Examples show proven parameter configurations from community workflows
|
||||
- Use as reference for proper parameter structure and values`;
|
||||
|
||||
const RESPONSE_FORMAT = `After validation passes, provide a concise summary:
|
||||
- List any placeholders requiring user configuration (e.g., "URL placeholder needs actual endpoint")
|
||||
- Note which nodes were configured and key settings applied
|
||||
@@ -136,6 +154,7 @@ export function buildConfiguratorPrompt(): string {
|
||||
.section('critical_parameters', CRITICAL_PARAMETERS)
|
||||
.section('default_values_warning', DEFAULT_VALUES_WARNING)
|
||||
.section('switch_node_configuration', SWITCH_NODE_CONFIGURATION)
|
||||
.section('node_configuration_examples', NODE_CONFIGURATION_EXAMPLES)
|
||||
.section('response_format', RESPONSE_FORMAT)
|
||||
.section('do_not', RESTRICTIONS)
|
||||
.build();
|
||||
|
||||
@@ -233,7 +233,8 @@ const CRITICAL_RULES = `- NEVER ask clarifying questions
|
||||
- NEVER guess node versions - always use search_nodes to find exact versions
|
||||
- ONLY flag connectionChangingParameters if they appear in <input> or <output> expressions
|
||||
- If no parameters appear in connection expressions, return empty array []
|
||||
- Output ONLY: nodesFound with {{ nodeName, version, reasoning, connectionChangingParameters }}`;
|
||||
- Output ONLY: nodesFound with {{ nodeName, version, reasoning, connectionChangingParameters }}
|
||||
- When user specifies a model name (e.g., 'gpt-4.1-mini') try to use this if it is a valid option`;
|
||||
|
||||
const RESTRICTIONS = `- Output text commentary between tool calls
|
||||
- Include bestPractices or categorization in submit_discovery_results
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import type { NodeTypeGuide } from '../types';
|
||||
|
||||
export const EMBEDDING_NODES_GUIDE: NodeTypeGuide = {
|
||||
patterns: ['@n8n/n8n-nodes-langchain.embeddings*'],
|
||||
content: `
|
||||
## CRITICAL: Embedding Models vs Chat Models
|
||||
|
||||
Embedding nodes (nodes with "embeddings" in their type name) MUST use embedding models, NOT chat/language models. Using a chat model in an embedding node will cause the workflow to fail.
|
||||
|
||||
### Common Mistake to Avoid
|
||||
NEVER configure an embedding node with chat models like:
|
||||
- gpt-4, gpt-4o, gpt-4o-mini, gpt-3.5-turbo (OpenAI chat models)
|
||||
- claude-3-opus, claude-3-sonnet (Anthropic chat models)
|
||||
- gemini-pro, gemini-1.5-pro (Google chat models)
|
||||
- llama-3, mixtral (general LLM models)
|
||||
|
||||
These are language/chat models designed for text generation, NOT for creating embeddings.
|
||||
|
||||
### Correct Embedding Models by Provider
|
||||
|
||||
#### OpenAI Embeddings
|
||||
- text-embedding-3-small (RECOMMENDED - default)
|
||||
- text-embedding-3-large
|
||||
- text-embedding-ada-002 (legacy)
|
||||
|
||||
#### AWS Bedrock Embeddings
|
||||
- amazon.titan-embed-text-v1
|
||||
- amazon.titan-embed-text-v2:0
|
||||
- cohere.embed-english-v3
|
||||
- cohere.embed-multilingual-v3
|
||||
|
||||
#### Google Gemini Embeddings
|
||||
- models/text-embedding-004 (RECOMMENDED - default)
|
||||
- models/embedding-001
|
||||
|
||||
#### Cohere Embeddings
|
||||
- embed-english-v3.0 (1024 dimensions)
|
||||
- embed-multilingual-v3.0 (1024 dimensions)
|
||||
- embed-english-light-v3.0 (384 dimensions)
|
||||
- embed-multilingual-light-v3.0 (384 dimensions)
|
||||
- embed-english-v2.0 (4096 dimensions)
|
||||
|
||||
#### Mistral Embeddings
|
||||
- mistral-embed (default)
|
||||
|
||||
#### Ollama Embeddings
|
||||
- nomic-embed-text
|
||||
- mxbai-embed-large
|
||||
- all-minilm
|
||||
- snowflake-arctic-embed
|
||||
|
||||
### How to Identify Embedding Models
|
||||
Embedding model names typically contain:
|
||||
- "embed" or "embedding" in the name
|
||||
- "e5", "bge", "gte" (common embedding model families)
|
||||
- "nomic", "minilm", "arctic" (embedding-specific models)
|
||||
|
||||
### Key Rules
|
||||
1. ALWAYS check if the node type contains "embeddings" - if so, use an embedding model
|
||||
2. If the user mentions a chat model (gpt-4, claude, gemini-pro, etc.) for embeddings, do NOT use it
|
||||
3. Suggest the appropriate embedding model from the same provider instead
|
||||
4. When in doubt, use the provider's default embedding model
|
||||
|
||||
### Parameter Names
|
||||
The model parameter may be named:
|
||||
- "model" (OpenAI, Bedrock, Mistral, Ollama, Azure)
|
||||
- "modelName" (Google Gemini, Cohere)`,
|
||||
};
|
||||
+1
@@ -5,6 +5,7 @@ export { SWITCH_NODE_GUIDE } from './switch-node';
|
||||
export { HTTP_REQUEST_GUIDE } from './http-request';
|
||||
export { TOOL_NODES_GUIDE } from './tool-nodes';
|
||||
export { GMAIL_GUIDE } from './gmail';
|
||||
export { EMBEDDING_NODES_GUIDE } from './embedding-nodes';
|
||||
|
||||
// Parameter-type guides
|
||||
export { RESOURCE_LOCATOR_GUIDE } from './resource-locator';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TOOL_NODE_EXAMPLES,
|
||||
} from './examples';
|
||||
import {
|
||||
EMBEDDING_NODES_GUIDE,
|
||||
GMAIL_GUIDE,
|
||||
HTTP_REQUEST_GUIDE,
|
||||
IF_NODE_GUIDE,
|
||||
@@ -41,6 +42,7 @@ const guides: NodeTypeGuide[] = [
|
||||
HTTP_REQUEST_GUIDE,
|
||||
TOOL_NODES_GUIDE,
|
||||
GMAIL_GUIDE,
|
||||
EMBEDDING_NODES_GUIDE,
|
||||
// Parameter-type guides
|
||||
RESOURCE_LOCATOR_GUIDE,
|
||||
SYSTEM_MESSAGE_GUIDE,
|
||||
|
||||
@@ -8,18 +8,20 @@ import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { LLMServiceError } from '@/errors';
|
||||
import { buildBuilderPrompt } from '@/prompts/agents/builder.prompt';
|
||||
import type { ChatPayload } from '@/workflow-builder-agent';
|
||||
import type { BuilderFeatureFlags, ChatPayload } from '@/workflow-builder-agent';
|
||||
|
||||
import { BaseSubgraph } from './subgraph-interface';
|
||||
import type { ParentGraphState } from '../parent-graph-state';
|
||||
import { createAddNodeTool } from '../tools/add-node.tool';
|
||||
import { createConnectNodesTool } from '../tools/connect-nodes.tool';
|
||||
import { createGetNodeConnectionExamplesTool } from '../tools/get-node-examples.tool';
|
||||
import { createRemoveConnectionTool } from '../tools/remove-connection.tool';
|
||||
import { createRemoveNodeTool } from '../tools/remove-node.tool';
|
||||
import { createValidateStructureTool } from '../tools/validate-structure.tool';
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import { createBuilderMetadata } from '../types/coordination';
|
||||
import type { DiscoveryContext } from '../types/discovery-types';
|
||||
import type { WorkflowMetadata } from '../types/tools';
|
||||
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
|
||||
import { applySubgraphCacheMarkers } from '../utils/cache-control';
|
||||
import {
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
createContextMessage,
|
||||
} from '../utils/context-builders';
|
||||
import { processOperations } from '../utils/operations-processor';
|
||||
import { cachedTemplatesReducer } from '../utils/state-reducers';
|
||||
import {
|
||||
executeSubgraphTools,
|
||||
extractUserRequest,
|
||||
@@ -77,12 +80,19 @@ export const BuilderSubgraphState = Annotation.Root({
|
||||
},
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Cached workflow templates (passed from parent, updated by tools)
|
||||
cachedTemplates: Annotation<WorkflowMetadata[]>({
|
||||
reducer: cachedTemplatesReducer,
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
||||
export interface BuilderSubgraphConfig {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
llm: BaseChatModel;
|
||||
logger?: Logger;
|
||||
featureFlags?: BuilderFeatureFlags;
|
||||
}
|
||||
|
||||
export class BuilderSubgraph extends BaseSubgraph<
|
||||
@@ -94,14 +104,22 @@ export class BuilderSubgraph extends BaseSubgraph<
|
||||
description = 'Constructs workflow structure: creating nodes and connections';
|
||||
|
||||
create(config: BuilderSubgraphConfig) {
|
||||
// Create tools
|
||||
const tools = [
|
||||
// Check if template examples are enabled
|
||||
const includeExamples = config.featureFlags?.templateExamples === true;
|
||||
|
||||
// Create base tools
|
||||
const baseTools = [
|
||||
createAddNodeTool(config.parsedNodeTypes),
|
||||
createConnectNodesTool(config.parsedNodeTypes, config.logger),
|
||||
createRemoveNodeTool(config.logger),
|
||||
createRemoveConnectionTool(config.logger),
|
||||
createValidateStructureTool(config.parsedNodeTypes),
|
||||
];
|
||||
|
||||
// Conditionally add node connection examples tool if feature flag is enabled
|
||||
const tools = includeExamples
|
||||
? [...baseTools, createGetNodeConnectionExamplesTool(config.logger)]
|
||||
: baseTools;
|
||||
const toolMap = new Map<string, StructuredTool>(tools.map((bt) => [bt.tool.name, bt.tool]));
|
||||
// Create agent with tools bound
|
||||
const systemPrompt = ChatPromptTemplate.fromMessages([
|
||||
@@ -199,6 +217,7 @@ export class BuilderSubgraph extends BaseSubgraph<
|
||||
workflowContext: parentState.workflowContext,
|
||||
discoveryContext: parentState.discoveryContext,
|
||||
messages: [contextMessage], // Context already in messages
|
||||
cachedTemplates: parentState.cachedTemplates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,6 +262,7 @@ export class BuilderSubgraph extends BaseSubgraph<
|
||||
workflowJSON: subgraphOutput.workflowJSON,
|
||||
workflowOperations: subgraphOutput.workflowOperations ?? [],
|
||||
coordinationLog: [logEntry],
|
||||
cachedTemplates: subgraphOutput.cachedTemplates,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { LLMServiceError } from '@/errors';
|
||||
import { buildConfiguratorPrompt, INSTANCE_URL_PROMPT } from '@/prompts/agents/configurator.prompt';
|
||||
import type { BuilderFeatureFlags, ChatPayload } from '@/workflow-builder-agent';
|
||||
|
||||
import { BaseSubgraph } from './subgraph-interface';
|
||||
import type { ParentGraphState } from '../parent-graph-state';
|
||||
import { createGetNodeConfigurationExamplesTool } from '../tools/get-node-examples.tool';
|
||||
import { createGetNodeParameterTool } from '../tools/get-node-parameter.tool';
|
||||
import { createUpdateNodeParametersTool } from '../tools/update-node-parameters.tool';
|
||||
import { createValidateConfigurationTool } from '../tools/validate-configuration.tool';
|
||||
@@ -19,6 +21,7 @@ import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import { createConfiguratorMetadata } from '../types/coordination';
|
||||
import type { DiscoveryContext } from '../types/discovery-types';
|
||||
import { isBaseMessage } from '../types/langchain';
|
||||
import type { WorkflowMetadata } from '../types/tools';
|
||||
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
|
||||
import { applySubgraphCacheMarkers } from '../utils/cache-control';
|
||||
import {
|
||||
@@ -27,12 +30,12 @@ import {
|
||||
createContextMessage,
|
||||
} from '../utils/context-builders';
|
||||
import { processOperations } from '../utils/operations-processor';
|
||||
import { cachedTemplatesReducer } from '../utils/state-reducers';
|
||||
import {
|
||||
executeSubgraphTools,
|
||||
extractUserRequest,
|
||||
createStandardShouldContinue,
|
||||
} from '../utils/subgraph-helpers';
|
||||
import type { ChatPayload } from '../workflow-builder-agent';
|
||||
|
||||
/**
|
||||
* Configurator Subgraph State
|
||||
@@ -82,6 +85,12 @@ export const ConfiguratorSubgraphState = Annotation.Root({
|
||||
},
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Cached workflow templates (passed from parent, updated by tools)
|
||||
cachedTemplates: Annotation<WorkflowMetadata[]>({
|
||||
reducer: cachedTemplatesReducer,
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
||||
export interface ConfiguratorSubgraphConfig {
|
||||
@@ -89,6 +98,7 @@ export interface ConfiguratorSubgraphConfig {
|
||||
llm: BaseChatModel;
|
||||
logger?: Logger;
|
||||
instanceUrl?: string;
|
||||
featureFlags?: BuilderFeatureFlags;
|
||||
}
|
||||
|
||||
export class ConfiguratorSubgraph extends BaseSubgraph<
|
||||
@@ -105,8 +115,12 @@ export class ConfiguratorSubgraph extends BaseSubgraph<
|
||||
|
||||
create(config: ConfiguratorSubgraphConfig) {
|
||||
this.instanceUrl = config.instanceUrl ?? '';
|
||||
// Create tools
|
||||
const tools = [
|
||||
|
||||
// Check if template examples are enabled
|
||||
const includeExamples = config.featureFlags?.templateExamples === true;
|
||||
|
||||
// Create base tools
|
||||
const baseTools = [
|
||||
createUpdateNodeParametersTool(
|
||||
config.parsedNodeTypes,
|
||||
config.llm, // Uses same LLM for parameter updater chain
|
||||
@@ -116,6 +130,11 @@ export class ConfiguratorSubgraph extends BaseSubgraph<
|
||||
createGetNodeParameterTool(),
|
||||
createValidateConfigurationTool(config.parsedNodeTypes),
|
||||
];
|
||||
|
||||
// Conditionally add node configuration examples tool if feature flag is enabled
|
||||
const tools = includeExamples
|
||||
? [...baseTools, createGetNodeConfigurationExamplesTool(config.logger)]
|
||||
: baseTools;
|
||||
this.toolMap = new Map<string, StructuredTool>(tools.map((bt) => [bt.tool.name, bt.tool]));
|
||||
// Create agent with tools bound
|
||||
const systemPromptTemplate = ChatPromptTemplate.fromMessages([
|
||||
@@ -214,6 +233,7 @@ export class ConfiguratorSubgraph extends BaseSubgraph<
|
||||
userRequest,
|
||||
discoveryContext: parentState.discoveryContext,
|
||||
messages: [contextMessage],
|
||||
cachedTemplates: parentState.cachedTemplates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,6 +269,8 @@ export class ConfiguratorSubgraph extends BaseSubgraph<
|
||||
workflowJSON: subgraphOutput.workflowJSON,
|
||||
workflowOperations: subgraphOutput.workflowOperations ?? [],
|
||||
coordinationLog: [logEntry],
|
||||
// Propagate cached templates back to parent
|
||||
cachedTemplates: subgraphOutput.cachedTemplates,
|
||||
// NO messages - clean separation from user-facing conversation
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ import { createNodeDetailsTool } from '../tools/node-details.tool';
|
||||
import { createNodeSearchTool } from '../tools/node-search.tool';
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import { createDiscoveryMetadata } from '../types/coordination';
|
||||
import type { NodeConfigurationsMap } from '../types/tools';
|
||||
import type { WorkflowMetadata } from '../types/tools';
|
||||
import { applySubgraphCacheMarkers } from '../utils/cache-control';
|
||||
import { buildWorkflowSummary, createContextMessage } from '../utils/context-builders';
|
||||
import { appendArrayReducer, nodeConfigurationsReducer } from '../utils/state-reducers';
|
||||
import { appendArrayReducer, cachedTemplatesReducer } from '../utils/state-reducers';
|
||||
import { executeSubgraphTools, extractUserRequest } from '../utils/subgraph-helpers';
|
||||
|
||||
/**
|
||||
@@ -106,11 +106,10 @@ export const DiscoverySubgraphState = Annotation.Root({
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Output: Node configurations collected from workflow examples
|
||||
// Used to provide example parameter configurations when get_node_details is called
|
||||
nodeConfigurations: Annotation<NodeConfigurationsMap>({
|
||||
reducer: nodeConfigurationsReducer,
|
||||
default: () => ({}),
|
||||
// Cached workflow templates (passed from parent, updated by tools)
|
||||
cachedTemplates: Annotation<WorkflowMetadata[]>({
|
||||
reducer: cachedTemplatesReducer,
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -143,7 +142,7 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
const baseTools = [
|
||||
createGetBestPracticesTool(),
|
||||
createNodeSearchTool(config.parsedNodeTypes),
|
||||
createNodeDetailsTool(config.parsedNodeTypes),
|
||||
createNodeDetailsTool(config.parsedNodeTypes, config.logger),
|
||||
];
|
||||
|
||||
// Conditionally add workflow examples tool if feature flag is enabled
|
||||
@@ -257,12 +256,11 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
(m): m is ToolMessage => m.getType() === 'tool' && m?.text?.startsWith('<best_practices>'),
|
||||
);
|
||||
|
||||
// Return raw output without hydration, including templateIds and nodeConfigurations from workflow examples
|
||||
// Return raw output without hydration, including templateIds from workflow examples
|
||||
return {
|
||||
nodesFound: output.nodesFound,
|
||||
bestPractices: bestPracticesTool?.text,
|
||||
templateIds: state.templateIds ?? [],
|
||||
nodeConfigurations: state.nodeConfigurations ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,6 +318,7 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
return {
|
||||
userRequest,
|
||||
messages: [contextMessage], // Context already in messages
|
||||
cachedTemplates: parentState.cachedTemplates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,11 +328,9 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
) {
|
||||
const nodesFound = subgraphOutput.nodesFound || [];
|
||||
const templateIds = subgraphOutput.templateIds || [];
|
||||
const nodeConfigurations = subgraphOutput.nodeConfigurations || {};
|
||||
const discoveryContext = {
|
||||
nodesFound,
|
||||
bestPractices: subgraphOutput.bestPractices,
|
||||
nodeConfigurations,
|
||||
};
|
||||
|
||||
// Create coordination log entry (not a message)
|
||||
@@ -354,8 +351,8 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
coordinationLog: [logEntry],
|
||||
// Pass template IDs for telemetry
|
||||
templateIds,
|
||||
// Pass node configurations for example parameters in node details
|
||||
nodeConfigurations,
|
||||
// Propagate cached templates back to parent
|
||||
cachedTemplates: subgraphOutput.cachedTemplates,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,8 +296,8 @@ describe('WorkflowBuilderAgent', () => {
|
||||
validationHistory: [],
|
||||
techniqueCategories: [],
|
||||
previousSummary: 'EMPTY',
|
||||
nodeConfigurations: {},
|
||||
templateIds: [],
|
||||
cachedTemplates: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ Leverage AI for unstructured data using nodes like Information Extractor or Summ
|
||||
|
||||
## Recommended Nodes
|
||||
|
||||
### Loop Over Items (n8n-nodes-base.splitInBatches)
|
||||
|
||||
Purpose: Looping over a set of items extracted from a data set, for example if pulling a lot of data
|
||||
from a Google Sheet or database then looping over the items is required. This node MUST be used
|
||||
if the user mentions a large amount of data, it is necessary to batch the data to process all of it.
|
||||
|
||||
### Extract From File (n8n-nodes-base.extractFromFile)
|
||||
|
||||
Purpose: Converts binary data from CSV, Excel, PDF, and text files to JSON for processing
|
||||
@@ -60,7 +66,11 @@ Purpose: Scrapes data from web pages using CSS selectors
|
||||
|
||||
### Split Out (n8n-nodes-base.splitOut)
|
||||
|
||||
Purpose: Processes arrays of items individually for sequential operations
|
||||
Purpose: Processes arrays of items individually for sequential operations.
|
||||
Example: If retrieving a JSON array using a HTTP request, this will return a single item,
|
||||
containing that array. If you wish to use a Loop Over Items (n8n-nodes-base.splitInBatches) node]
|
||||
then you will need to split out the array into items before looping over it. In a scenario like
|
||||
this a split out node MUST be used before looping over the items.
|
||||
|
||||
### Edit Fields (Set) (n8n-nodes-base.set)
|
||||
|
||||
|
||||
+12
-1
@@ -61,6 +61,11 @@ For high-volume processing:
|
||||
- Process files sequentially or in small batches
|
||||
- Drop unnecessary binary data after extraction to free memory
|
||||
|
||||
### File Metadata
|
||||
Documents uploaded via a form trigger will have various bits of metadata available - filename, mimetype and size.
|
||||
These are accessible using an expression like {{ $json.documents[0].mimetype }} to access each of the document's details.
|
||||
Multiple files can be uploaded to a form which is the reason for the documents array.
|
||||
|
||||
## Text Extraction Strategy
|
||||
|
||||
Choose extraction method based on document type and content:
|
||||
@@ -168,8 +173,14 @@ Configuration: Set appropriate folder and file type filters
|
||||
**Extract from File (n8n-nodes-base.extractFromFile)**
|
||||
Purpose: Extract text from various file formats using format-specific operations
|
||||
Critical: ALWAYS check file type first with an IF or Switch before and select the correct operation (Extract from PDF, Extract from MS Excel, etc.)
|
||||
Critical: If the user requests handling of multiple file types (PDF, CSV, JSON, etc) then a Switch (n8n-nodes-base.switch) node should be used
|
||||
to check the file type before text extraction. Multiple text extraction nodes should be used to handle each of the different file types. For example,
|
||||
if the workflow contains a form trigger node which receives a file, then a Switch node MUST be used to split the different options out to different extraction nodes.
|
||||
Output: Extracted text is returned under the "text" key in JSON (e.g., access with {{ $json.text }})
|
||||
Pitfalls: Returns empty for scanned documents - always check and fallback to OCR; Using wrong operation causes errors
|
||||
Pitfalls:
|
||||
- Returns empty for scanned documents - always check and fallback to OCR; Using wrong operation causes errors
|
||||
- If connecting to a document upload form (n8n-nodes-base.formTrigger) use a File field type and then connect it to the extract from file node using the field name.
|
||||
For example if creating a form trigger with field "Upload Document" then set the extract from file input binary field to "Upload_Document"
|
||||
|
||||
**AWS Textract (n8n-nodes-base.awsTextract)**
|
||||
Purpose: Advanced OCR with table and form detection
|
||||
|
||||
+10
@@ -43,6 +43,16 @@ Pitfalls:
|
||||
Fail" feature
|
||||
- Refresh expired tokens, verify API keys, and ensure correct permissions to avoid authentication failures
|
||||
|
||||
### SerpAPI (@n8n/n8n-nodes-langchain.toolSerpApi)
|
||||
|
||||
Purpose: Give an agent the ability to search for research materials and fact-checking results that have been retrieved
|
||||
from other sources.
|
||||
|
||||
### Perplexity (n8n-nodes-base.perplexityTool)
|
||||
|
||||
Purpose: Give an agent the ability to search utilising Perplexity, a powerful tool for finding sources/material for
|
||||
generating reports and information.
|
||||
|
||||
### HTML Extract (n8n-nodes-base.htmlExtract)
|
||||
|
||||
Purpose: Parses HTML and extracts data using CSS selectors for web scraping
|
||||
|
||||
@@ -51,7 +51,7 @@ export function getBuilderTools({
|
||||
// Add remaining tools
|
||||
tools.push(
|
||||
createNodeSearchTool(parsedNodeTypes),
|
||||
createNodeDetailsTool(parsedNodeTypes),
|
||||
createNodeDetailsTool(parsedNodeTypes, logger),
|
||||
createAddNodeTool(parsedNodeTypes),
|
||||
createConnectNodesTool(parsedNodeTypes, logger),
|
||||
createRemoveConnectionTool(logger),
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { WorkflowMetadata } from '@/types';
|
||||
import type { BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import {
|
||||
createProgressReporter,
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
getWorkflowState,
|
||||
reportProgress,
|
||||
} from './helpers';
|
||||
import { mermaidStringify } from './utils/mermaid.utils';
|
||||
import {
|
||||
formatNodeConfigurationExamples,
|
||||
getNodeConfigurationsFromTemplates,
|
||||
} from './utils/node-configuration.utils';
|
||||
import type { NodeConfigurationEntry } from '../types/tools';
|
||||
import { fetchWorkflowsFromTemplates } from './web/templates';
|
||||
|
||||
/**
|
||||
* Schema for a single node request
|
||||
*/
|
||||
const nodeRequestSchema = z.object({
|
||||
nodeType: z.string().describe('The exact node type name (e.g., n8n-nodes-base.httpRequest)'),
|
||||
nodeVersion: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Optional specific node version to filter examples by'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for get node examples tool input - accepts a list of nodes
|
||||
*/
|
||||
const getNodeExamplesSchema = z.object({
|
||||
nodes: z
|
||||
.array(nodeRequestSchema)
|
||||
.min(1)
|
||||
.max(10)
|
||||
.describe('List of nodes to get examples for (1-10 nodes)'),
|
||||
});
|
||||
|
||||
/** Example type determines what format the tool returns */
|
||||
export type NodeExampleType = 'configuration' | 'connections';
|
||||
|
||||
/** Tool configuration by example type */
|
||||
const TOOL_CONFIG: Record<NodeExampleType, { meta: BuilderToolBase; description: string }> = {
|
||||
configuration: {
|
||||
meta: {
|
||||
toolName: 'get_node_configuration_examples',
|
||||
displayTitle: 'Getting node configuration examples',
|
||||
},
|
||||
description: `Get real-world parameter configuration examples for multiple node types from community templates.
|
||||
|
||||
Use this tool when you need reference examples for configuring node parameters:
|
||||
- When you need to understand proper parameter structure
|
||||
- To see how templated workflows configure specific integrations
|
||||
|
||||
Parameters:
|
||||
- nodes: Array of objects with nodeType (required) and nodeVersion (optional)
|
||||
Example: [{ nodeType: "n8n-nodes-base.httpRequest" }, { nodeType: "n8n-nodes-base.gmail", nodeVersion: 2 }]
|
||||
|
||||
Returns markdown-formatted examples showing proven parameter configurations for each node.`,
|
||||
},
|
||||
connections: {
|
||||
meta: {
|
||||
toolName: 'get_node_connection_examples',
|
||||
displayTitle: 'Getting node connection examples',
|
||||
},
|
||||
description: `Get mermaid diagrams showing how specific node types are typically connected in real workflows.
|
||||
|
||||
Use this tool when you need to understand node connection patterns:
|
||||
- When connecting nodes with non-standard output patterns (e.g., splitInBatches, Switch, IF)
|
||||
- To see how nodes are typically placed in workflow flows
|
||||
- To understand which nodes typically come before/after specific nodes
|
||||
|
||||
Parameters:
|
||||
- nodes: Array of objects with nodeType (required) and nodeVersion (optional)
|
||||
Example: [{ nodeType: "n8n-nodes-base.splitInBatches" }, { nodeType: "n8n-nodes-base.if" }]
|
||||
|
||||
Returns mermaid diagrams from community workflows containing each node.`,
|
||||
},
|
||||
};
|
||||
|
||||
/** Result from workflow retrieval */
|
||||
interface WorkflowRetrievalResult {
|
||||
workflows: WorkflowMetadata[];
|
||||
nodeConfigs: NodeConfigurationEntry[];
|
||||
newTemplates: WorkflowMetadata[];
|
||||
}
|
||||
|
||||
/** Options for getWorkflowsForNodeType */
|
||||
interface GetWorkflowsOptions {
|
||||
nodeType: string;
|
||||
logger?: Logger;
|
||||
onProgress?: (message: string) => void;
|
||||
/** Local cache of templates accumulated during batch processing */
|
||||
localCache?: WorkflowMetadata[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single retrieval function for getting workflows containing a specific node type.
|
||||
* Checks local cache first, then state cache, then fetches from API if needed.
|
||||
*/
|
||||
async function getWorkflowsForNodeType({
|
||||
nodeType,
|
||||
logger,
|
||||
onProgress,
|
||||
localCache = [],
|
||||
}: GetWorkflowsOptions): Promise<WorkflowRetrievalResult> {
|
||||
// First check local cache (templates fetched earlier in the same batch)
|
||||
const relevantFromLocal = localCache.filter((wf) =>
|
||||
wf.workflow.nodes.some((n) => n.type === nodeType),
|
||||
);
|
||||
|
||||
if (relevantFromLocal.length > 0) {
|
||||
const nodeConfigs = getNodeConfigurationsFromTemplates(relevantFromLocal, nodeType);
|
||||
|
||||
logger?.debug('Found node configurations in local batch cache', {
|
||||
nodeType,
|
||||
configCount: nodeConfigs.length,
|
||||
workflowCount: relevantFromLocal.length,
|
||||
});
|
||||
|
||||
return {
|
||||
workflows: relevantFromLocal,
|
||||
nodeConfigs,
|
||||
newTemplates: [], // Already in local cache, not "new"
|
||||
};
|
||||
}
|
||||
|
||||
// Then check state cache (templates from previous tool calls)
|
||||
let stateCachedTemplates: WorkflowMetadata[] = [];
|
||||
try {
|
||||
const state = getWorkflowState();
|
||||
stateCachedTemplates = state?.cachedTemplates ?? [];
|
||||
} catch {
|
||||
// State may not be available in some contexts
|
||||
}
|
||||
|
||||
const relevantFromState = stateCachedTemplates.filter((wf) =>
|
||||
wf.workflow.nodes.some((n) => n.type === nodeType),
|
||||
);
|
||||
|
||||
if (relevantFromState.length > 0) {
|
||||
const nodeConfigs = getNodeConfigurationsFromTemplates(relevantFromState, nodeType);
|
||||
|
||||
logger?.debug('Found node configurations in state cache', {
|
||||
nodeType,
|
||||
configCount: nodeConfigs.length,
|
||||
workflowCount: relevantFromState.length,
|
||||
});
|
||||
|
||||
return {
|
||||
workflows: relevantFromState,
|
||||
nodeConfigs,
|
||||
newTemplates: [],
|
||||
};
|
||||
}
|
||||
|
||||
// No cached data, fetch from templates API
|
||||
onProgress?.(`Fetching examples for ${nodeType}...`);
|
||||
|
||||
try {
|
||||
const result = await fetchWorkflowsFromTemplates(
|
||||
{ nodes: nodeType, rows: 5 },
|
||||
{ maxTemplates: 5, logger },
|
||||
);
|
||||
|
||||
if (result.workflows.length > 0) {
|
||||
const nodeConfigs = getNodeConfigurationsFromTemplates(result.workflows, nodeType);
|
||||
|
||||
logger?.debug('Fetched workflows from templates API', {
|
||||
nodeType,
|
||||
configCount: nodeConfigs.length,
|
||||
workflowCount: result.workflows.length,
|
||||
});
|
||||
|
||||
return {
|
||||
workflows: result.workflows,
|
||||
nodeConfigs,
|
||||
newTemplates: result.workflows,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger?.warn('Failed to fetch node examples from templates', { nodeType, error });
|
||||
}
|
||||
|
||||
return {
|
||||
workflows: [],
|
||||
nodeConfigs: [],
|
||||
newTemplates: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Maximum number of examples to return to avoid context overload */
|
||||
const MAX_EXAMPLES = 1;
|
||||
|
||||
/**
|
||||
* Generate mermaid connection examples for a specific node type
|
||||
*/
|
||||
function formatConnectionExamples(nodeType: string, workflows: WorkflowMetadata[]): string {
|
||||
const shortNodeType = nodeType.split('.').pop() ?? nodeType;
|
||||
|
||||
if (workflows.length === 0) {
|
||||
return `## Node Connection Examples: ${nodeType}\n\nNo connection examples found.`;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`## Node Connection Examples: ${nodeType}`,
|
||||
'',
|
||||
`These mermaid diagrams show workflows containing **${shortNodeType}**.`,
|
||||
'',
|
||||
'Look for the target node in each diagram to understand:',
|
||||
'- Which nodes typically come BEFORE this node (incoming connections)',
|
||||
'- Which nodes typically come AFTER this node (outgoing connections)',
|
||||
'- For multi-output nodes like splitInBatches: output 0 = "done" branch, output 1 = "loop" branch',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const workflow of workflows.slice(0, MAX_EXAMPLES)) {
|
||||
const mermaid = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
lines.push(`### Example: ${workflow.name}`, '', '```mermaid', mermaid, '```', '');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool errors consistently
|
||||
*/
|
||||
function handleToolError(
|
||||
error: unknown,
|
||||
reporter: ReturnType<typeof createProgressReporter>,
|
||||
toolName: string,
|
||||
config: Parameters<typeof createErrorResponse>[0],
|
||||
) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const validationError = new ValidationError('Invalid input parameters', {
|
||||
extra: { errors: error.errors },
|
||||
});
|
||||
reporter.error(validationError);
|
||||
return createErrorResponse(config, validationError);
|
||||
}
|
||||
|
||||
const toolError = new ToolExecutionError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
{ toolName, cause: error instanceof Error ? error : undefined },
|
||||
);
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
|
||||
/** Options for creating the node examples tool */
|
||||
interface CreateNodeExamplesToolOptions {
|
||||
exampleType: NodeExampleType;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a node examples tool.
|
||||
* Use exampleType to determine whether it returns configuration or connection examples.
|
||||
*/
|
||||
export function createGetNodeExamplesTool({ exampleType, logger }: CreateNodeExamplesToolOptions) {
|
||||
const { meta: toolMeta, description } = TOOL_CONFIG[exampleType];
|
||||
|
||||
const dynamicTool = tool(
|
||||
async (input, config) => {
|
||||
const reporter = createProgressReporter(config, toolMeta.toolName, toolMeta.displayTitle);
|
||||
|
||||
try {
|
||||
const validatedInput = getNodeExamplesSchema.parse(input);
|
||||
const { nodes } = validatedInput;
|
||||
|
||||
reporter.start(validatedInput);
|
||||
|
||||
// Process all nodes and collect results
|
||||
// Use localCache to accumulate templates during batch processing
|
||||
// so subsequent nodes can benefit from earlier fetches
|
||||
const allMessages: string[] = [];
|
||||
const allNewTemplates: WorkflowMetadata[] = [];
|
||||
let totalFound = 0;
|
||||
|
||||
for (const { nodeType, nodeVersion } of nodes) {
|
||||
const result = await getWorkflowsForNodeType({
|
||||
nodeType,
|
||||
logger,
|
||||
onProgress: (msg: string) => reportProgress(reporter, msg),
|
||||
localCache: allNewTemplates, // Pass accumulated templates
|
||||
});
|
||||
|
||||
// Format based on example type
|
||||
const message =
|
||||
exampleType === 'configuration'
|
||||
? formatNodeConfigurationExamples(nodeType, result.nodeConfigs, nodeVersion)
|
||||
: formatConnectionExamples(nodeType, result.workflows);
|
||||
|
||||
allMessages.push(message);
|
||||
// Add new templates to local cache for subsequent iterations
|
||||
allNewTemplates.push(...result.newTemplates);
|
||||
totalFound +=
|
||||
exampleType === 'configuration' ? result.nodeConfigs.length : result.workflows.length;
|
||||
}
|
||||
|
||||
const combinedMessage = allMessages.join('\n\n---\n\n');
|
||||
const nodeTypes = nodes.map((n) => n.nodeType);
|
||||
|
||||
reporter.complete({ nodeTypes, totalFound, message: combinedMessage });
|
||||
|
||||
// Build state updates - only add new templates if fetched from API
|
||||
const stateUpdates: Record<string, unknown> = {};
|
||||
if (allNewTemplates.length > 0) {
|
||||
stateUpdates.cachedTemplates = allNewTemplates;
|
||||
}
|
||||
|
||||
return createSuccessResponse(
|
||||
config,
|
||||
combinedMessage,
|
||||
Object.keys(stateUpdates).length > 0 ? stateUpdates : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
return handleToolError(error, reporter, toolMeta.toolName, config);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: toolMeta.toolName,
|
||||
description,
|
||||
schema: getNodeExamplesSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return { tool: dynamicTool, ...toolMeta };
|
||||
}
|
||||
|
||||
// Convenience factory functions for backward compatibility
|
||||
export const createGetNodeConfigurationExamplesTool = (logger?: Logger) =>
|
||||
createGetNodeExamplesTool({ exampleType: 'configuration', logger });
|
||||
|
||||
export const createGetNodeConnectionExamplesTool = (logger?: Logger) =>
|
||||
createGetNodeExamplesTool({ exampleType: 'connections', logger });
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
} from './helpers';
|
||||
import { processWorkflowExamples } from './utils/markdown-workflow.utils';
|
||||
import { fetchTemplateList, fetchTemplateByID } from './web/templates';
|
||||
import { processWorkflowExamples } from './utils/mermaid.utils';
|
||||
import { fetchWorkflowsFromTemplates } from './web/templates';
|
||||
|
||||
/**
|
||||
* Workflow example query schema
|
||||
@@ -32,67 +32,6 @@ const getWorkflowExamplesSchema = z.object({
|
||||
.describe('Array of search queries to find workflow examples'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Inferred types from schemas
|
||||
*/
|
||||
type WorkflowExampleQuery = z.infer<typeof workflowExampleQuerySchema>;
|
||||
|
||||
/**
|
||||
* Result of fetching workflow examples including template IDs for telemetry
|
||||
*/
|
||||
interface FetchWorkflowExamplesResult {
|
||||
workflows: WorkflowMetadata[];
|
||||
totalFound: number;
|
||||
templateIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workflow examples from the API
|
||||
*/
|
||||
async function fetchWorkflowExamples(
|
||||
query: WorkflowExampleQuery,
|
||||
logger?: Logger,
|
||||
): Promise<FetchWorkflowExamplesResult> {
|
||||
logger?.debug('Fetching workflow examples with query', { query });
|
||||
|
||||
// First, fetch the list of workflow templates (metadata)
|
||||
const response = await fetchTemplateList({
|
||||
search: query.search,
|
||||
});
|
||||
|
||||
// Then fetch complete workflow data for each template
|
||||
const workflowResults: Array<{ metadata: WorkflowMetadata; templateId: number } | undefined> =
|
||||
await Promise.all(
|
||||
response.workflows.map(async (workflow) => {
|
||||
try {
|
||||
const fullWorkflow = await fetchTemplateByID(workflow.id);
|
||||
return {
|
||||
metadata: {
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
workflow: fullWorkflow.workflow,
|
||||
},
|
||||
templateId: workflow.id,
|
||||
};
|
||||
} catch (error) {
|
||||
// failed to fetch a workflow, ignore it for now
|
||||
logger?.warn(`Failed to fetch full workflow for template ${workflow.id}`, { error });
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const validResults = workflowResults.filter(
|
||||
(result): result is { metadata: WorkflowMetadata; templateId: number } => result !== undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
workflows: validResults.map((r) => r.metadata),
|
||||
totalFound: response.totalWorkflows,
|
||||
templateIds: validResults.map((r) => r.templateId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable identifier for a query
|
||||
*/
|
||||
@@ -182,8 +121,8 @@ export function createGetWorkflowExamplesTool(logger?: Logger) {
|
||||
// Report progress
|
||||
batchReporter.next(identifier);
|
||||
|
||||
// Fetch workflow examples
|
||||
const result = await fetchWorkflowExamples(query, logger);
|
||||
// Fetch workflow examples using shared utility
|
||||
const result = await fetchWorkflowsFromTemplates({ search: query.search }, { logger });
|
||||
|
||||
// Add to results
|
||||
allResults = allResults.concat(result.workflows);
|
||||
@@ -205,26 +144,11 @@ export function createGetWorkflowExamplesTool(logger?: Logger) {
|
||||
}
|
||||
const deduplicatedResults = Array.from(uniqueWorkflows.values());
|
||||
|
||||
// Process workflows to get mermaid diagrams and collect node configurations in one pass
|
||||
// Process workflows to get mermaid diagrams
|
||||
const processedResults = processWorkflowExamples(deduplicatedResults, {
|
||||
includeNodeParameters: false,
|
||||
});
|
||||
|
||||
// Get the accumulated node configurations from the last result (all results share the same map)
|
||||
const nodeConfigurations =
|
||||
processedResults.length > 0
|
||||
? processedResults[processedResults.length - 1].nodeConfigurations
|
||||
: {};
|
||||
|
||||
// Debug: Log the collected configurations
|
||||
logger?.debug('Collected node configurations from workflow examples', {
|
||||
nodeTypeCount: Object.keys(nodeConfigurations).length,
|
||||
nodeTypes: Object.keys(nodeConfigurations),
|
||||
configCounts: Object.fromEntries(
|
||||
Object.entries(nodeConfigurations).map(([type, configs]) => [type, configs.length]),
|
||||
),
|
||||
});
|
||||
|
||||
// Build output with formatted results
|
||||
const formattedResults = deduplicatedResults.map((workflow, index) => ({
|
||||
name: workflow.name,
|
||||
@@ -234,7 +158,6 @@ export function createGetWorkflowExamplesTool(logger?: Logger) {
|
||||
const output: GetWorkflowExamplesOutput = {
|
||||
examples: formattedResults,
|
||||
totalResults: deduplicatedResults.length,
|
||||
nodeConfigurations,
|
||||
};
|
||||
|
||||
// Build response message and report
|
||||
@@ -244,10 +167,16 @@ export function createGetWorkflowExamplesTool(logger?: Logger) {
|
||||
// Deduplicate template IDs
|
||||
const uniqueTemplateIds = [...new Set(allTemplateIds)];
|
||||
|
||||
// Return success response with node configurations and template IDs stored in state
|
||||
// Debug: Log what we're caching
|
||||
logger?.debug('Caching workflow templates in state', {
|
||||
templateCount: deduplicatedResults.length,
|
||||
templateNames: deduplicatedResults.map((w) => w.name),
|
||||
});
|
||||
|
||||
// Return success response with templates and template IDs stored in state
|
||||
return createSuccessResponse(config, responseMessage, {
|
||||
nodeConfigurations,
|
||||
templateIds: uniqueTemplateIds,
|
||||
cachedTemplates: deduplicatedResults,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle validation or unexpected errors
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { INodeParameters, INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MAX_NODE_EXAMPLE_CHARS } from '@/constants';
|
||||
import type { NodeConfigurationEntry } from '@/types';
|
||||
import type { BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
@@ -11,7 +13,12 @@ import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
import { getWorkflowState } from './helpers/state';
|
||||
import { findNodeType, createNodeTypeNotFoundError } from './helpers/validation';
|
||||
import type { NodeDetails } from '../types/nodes';
|
||||
import type { NodeDetailsOutput } from '../types/tools';
|
||||
import type { NodeDetailsOutput, WorkflowMetadata } from '../types/tools';
|
||||
import { getNodeConfigurationsFromTemplates } from './utils/node-configuration.utils';
|
||||
import { fetchWorkflowsFromTemplates } from './web/templates';
|
||||
|
||||
/** Maximum number of example configurations to include */
|
||||
const MAX_NODE_EXAMPLES = 5;
|
||||
|
||||
/**
|
||||
* Schema for node details tool input
|
||||
@@ -78,7 +85,7 @@ function formatNodeDetails(
|
||||
details: NodeDetails,
|
||||
withParameters: boolean = false,
|
||||
withConnections: boolean = true,
|
||||
examples: INodeParameters[] = [],
|
||||
examples: NodeConfigurationEntry[] = [],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -108,13 +115,14 @@ function formatNodeDetails(
|
||||
parts.push('</connections>');
|
||||
}
|
||||
|
||||
// Example configurations from workflow examples (with token limit)
|
||||
// Example configurations from workflow examples (with token limit, max 5)
|
||||
if (examples.length > 0) {
|
||||
const { parts: exampleParts } = examples.reduce<{ parts: string[]; chars: number }>(
|
||||
(acc, example) => {
|
||||
const exampleStr = JSON.stringify(example, null, 2);
|
||||
const limitedExamples = examples.slice(0, MAX_NODE_EXAMPLES);
|
||||
const { parts: exampleParts } = limitedExamples.reduce<{ parts: string[]; chars: number }>(
|
||||
(acc, config) => {
|
||||
const exampleStr = JSON.stringify(config.parameters, null, 2);
|
||||
if (acc.chars + exampleStr.length <= MAX_NODE_EXAMPLE_CHARS) {
|
||||
acc.parts.push(exampleStr);
|
||||
acc.parts.push(`<example>\n${exampleStr}\n</example>`);
|
||||
acc.chars += exampleStr.length;
|
||||
}
|
||||
return acc;
|
||||
@@ -154,12 +162,81 @@ export const NODE_DETAILS_TOOL: BuilderToolBase = {
|
||||
displayTitle: 'Getting node details',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get example configurations for a node type.
|
||||
* First checks the cached templates, then fetches from templates API if none found.
|
||||
*/
|
||||
async function getNodeExamples(
|
||||
nodeName: string,
|
||||
nodeVersion: number,
|
||||
logger?: Logger,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<{
|
||||
examples: NodeConfigurationEntry[];
|
||||
newTemplates?: WorkflowMetadata[];
|
||||
}> {
|
||||
// First, try to get examples from cached templates
|
||||
try {
|
||||
const state = getWorkflowState();
|
||||
const cachedTemplates = state?.cachedTemplates ?? [];
|
||||
|
||||
// Extract configurations directly from cached templates
|
||||
const filteredConfigs = getNodeConfigurationsFromTemplates(
|
||||
cachedTemplates,
|
||||
nodeName,
|
||||
nodeVersion,
|
||||
);
|
||||
|
||||
if (filteredConfigs.length > 0) {
|
||||
logger?.debug('Found node configurations in cached templates', {
|
||||
nodeName,
|
||||
nodeVersion,
|
||||
count: filteredConfigs.length,
|
||||
});
|
||||
return { examples: filteredConfigs };
|
||||
}
|
||||
} catch {
|
||||
// State may not be available in some environments
|
||||
}
|
||||
|
||||
// No cached data, fetch from templates API
|
||||
onProgress?.(`Fetching examples for ${nodeName}...`);
|
||||
|
||||
try {
|
||||
const result = await fetchWorkflowsFromTemplates(
|
||||
{ nodes: nodeName, rows: 10 },
|
||||
{ maxTemplates: 5, logger },
|
||||
);
|
||||
|
||||
if (result.workflows.length > 0) {
|
||||
const nodeConfigs = getNodeConfigurationsFromTemplates(
|
||||
result.workflows,
|
||||
nodeName,
|
||||
nodeVersion,
|
||||
);
|
||||
|
||||
logger?.debug('Fetched node configurations from templates API', {
|
||||
nodeName,
|
||||
nodeVersion,
|
||||
count: nodeConfigs.length,
|
||||
workflowCount: result.workflows.length,
|
||||
});
|
||||
|
||||
return { examples: nodeConfigs, newTemplates: result.workflows };
|
||||
}
|
||||
} catch (error) {
|
||||
logger?.warn('Failed to fetch node examples from templates', { nodeName, error });
|
||||
}
|
||||
|
||||
return { examples: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create the node details tool
|
||||
*/
|
||||
export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[], logger?: Logger) {
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
async (input: unknown, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
NODE_DETAILS_TOOL.toolName,
|
||||
@@ -189,18 +266,13 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
// Extract node details
|
||||
const details = extractNodeDetails(nodeType);
|
||||
|
||||
// Get example configurations from state, filtered by node type and version
|
||||
let examples: INodeParameters[] = [];
|
||||
try {
|
||||
const state = getWorkflowState();
|
||||
const allNodeConfigs = state?.nodeConfigurations?.[nodeName] ?? [];
|
||||
examples = allNodeConfigs
|
||||
.filter((config) => config.version === nodeVersion)
|
||||
.map((config) => config.parameters);
|
||||
} catch {
|
||||
// State may not be available in test environments
|
||||
examples = [];
|
||||
}
|
||||
// Get example configurations (from cache or fetch from templates)
|
||||
const { examples, newTemplates } = await getNodeExamples(
|
||||
nodeName,
|
||||
nodeVersion,
|
||||
logger,
|
||||
(msg) => reportProgress(reporter, msg),
|
||||
);
|
||||
|
||||
// Format the output message with examples
|
||||
const message = formatNodeDetails(details, withParameters, withConnections, examples);
|
||||
@@ -213,8 +285,10 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
};
|
||||
reporter.complete(output);
|
||||
|
||||
// Return success response
|
||||
return createSuccessResponse(config, message);
|
||||
// Return success response with state updates if we fetched new templates
|
||||
const stateUpdates = newTemplates ? { cachedTemplates: newTemplates } : undefined;
|
||||
|
||||
return createSuccessResponse(config, message, stateUpdates);
|
||||
} catch (error) {
|
||||
// Handle validation or unexpected errors
|
||||
if (error instanceof z.ZodError) {
|
||||
@@ -239,7 +313,7 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
{
|
||||
name: NODE_DETAILS_TOOL.toolName,
|
||||
description:
|
||||
'Get detailed information about a specific n8n node type including properties and available connections. Use this before adding nodes to understand their input/output structure.',
|
||||
'Get detailed information about a specific n8n node type including properties, available connections, and up to 5 example configurations. Use this before adding nodes to understand their input/output structure.',
|
||||
schema: nodeDetailsSchema,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -184,7 +184,7 @@ describe('builder-tools', () => {
|
||||
expect(createGetBestPracticesTool).toHaveBeenCalled();
|
||||
expect(createGetWorkflowExamplesTool).toHaveBeenCalledWith(mockLogger);
|
||||
expect(createNodeSearchTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(createNodeDetailsTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(createNodeDetailsTool).toHaveBeenCalledWith(parsedNodeTypes, mockLogger);
|
||||
expect(createAddNodeTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(createRemoveConnectionTool).toHaveBeenCalled();
|
||||
expect(createConnectNodesTool).toHaveBeenCalledWith(parsedNodeTypes, mockLogger);
|
||||
@@ -244,7 +244,7 @@ describe('builder-tools', () => {
|
||||
});
|
||||
|
||||
expect(createNodeSearchTool).toHaveBeenCalledWith(customNodeTypes);
|
||||
expect(createNodeDetailsTool).toHaveBeenCalledWith(customNodeTypes);
|
||||
expect(createNodeDetailsTool).toHaveBeenCalledWith(customNodeTypes, undefined);
|
||||
expect(createAddNodeTool).toHaveBeenCalledWith(customNodeTypes);
|
||||
expect(createConnectNodesTool).toHaveBeenCalledWith(customNodeTypes, undefined);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
import type { IConnections, INode } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
parseToolResult,
|
||||
createToolConfig,
|
||||
expectToolSuccess,
|
||||
type ParsedToolContent,
|
||||
createNode,
|
||||
} from '../../../test/test-utils';
|
||||
import type { WorkflowMetadata } from '../../types/tools';
|
||||
import {
|
||||
createGetNodeConfigurationExamplesTool,
|
||||
createGetNodeConnectionExamplesTool,
|
||||
} from '../get-node-examples.tool';
|
||||
import type { FetchWorkflowsResult } from '../web/templates';
|
||||
import * as templates from '../web/templates';
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
jest.mock('@langchain/langgraph', () => ({
|
||||
getCurrentTaskInput: jest.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
|
||||
content: JSON.stringify(params),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the templates module
|
||||
jest.mock('../web/templates');
|
||||
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
const mockFetchWorkflowsFromTemplates =
|
||||
templates.fetchWorkflowsFromTemplates as jest.MockedFunction<
|
||||
typeof templates.fetchWorkflowsFromTemplates
|
||||
>;
|
||||
|
||||
describe('GetNodeExamplesTool', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Default: no cached templates
|
||||
mockGetCurrentTaskInput.mockReturnValue({
|
||||
cachedTemplates: [],
|
||||
workflowJSON: { nodes: [], connections: {}, name: 'Test' },
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create mock workflow metadata with specific nodes
|
||||
let mockTemplateIdCounter = 1;
|
||||
const createMockWorkflow = (
|
||||
name: string,
|
||||
nodes: INode[],
|
||||
connections: IConnections = {},
|
||||
): WorkflowMetadata => ({
|
||||
templateId: mockTemplateIdCounter++,
|
||||
name,
|
||||
description: `Workflow: ${name}`,
|
||||
workflow: { nodes, connections, name },
|
||||
});
|
||||
|
||||
// Helper to create mock fetch result
|
||||
const createMockFetchResult = (workflows: WorkflowMetadata[]): FetchWorkflowsResult => ({
|
||||
workflows,
|
||||
totalFound: workflows.length,
|
||||
templateIds: workflows.map((_, i) => i + 1),
|
||||
});
|
||||
|
||||
describe('configuration examples', () => {
|
||||
let configTool: ReturnType<typeof createGetNodeConfigurationExamplesTool>['tool'];
|
||||
|
||||
beforeEach(() => {
|
||||
configTool = createGetNodeConfigurationExamplesTool().tool;
|
||||
});
|
||||
|
||||
it('should fetch configuration examples from API', async () => {
|
||||
const mockConfig = createToolConfig('get_node_configuration_examples', 'test-1');
|
||||
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue(
|
||||
createMockFetchResult([
|
||||
createMockWorkflow('API Workflow', [
|
||||
createNode({
|
||||
id: 'http-1',
|
||||
name: 'Fetch Data',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
parameters: { url: 'https://api.example.com', method: 'GET' },
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await configTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.httpRequest' }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'Node Configuration Examples');
|
||||
expect(message).toContain('httpRequest');
|
||||
expect(message).toContain('https://api.example.com');
|
||||
expect(mockFetchWorkflowsFromTemplates).toHaveBeenCalledWith(
|
||||
{ nodes: 'n8n-nodes-base.httpRequest', rows: 5 },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use cached templates when available', async () => {
|
||||
const mockConfig = createToolConfig('get_node_configuration_examples', 'test-2');
|
||||
|
||||
// Set up cached templates
|
||||
mockGetCurrentTaskInput.mockReturnValue({
|
||||
cachedTemplates: [
|
||||
createMockWorkflow('Cached Workflow', [
|
||||
createNode({
|
||||
id: 'code-1',
|
||||
name: 'Transform',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 1,
|
||||
parameters: { jsCode: 'return items;', mode: 'runOnceForAllItems' },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
workflowJSON: { nodes: [], connections: {}, name: 'Test' },
|
||||
messages: [],
|
||||
});
|
||||
|
||||
const result = await configTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.code' }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'Node Configuration Examples');
|
||||
expect(message).toContain('return items;');
|
||||
// Should NOT call API since we found cached data
|
||||
expect(mockFetchWorkflowsFromTemplates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter by node version when specified', async () => {
|
||||
const mockConfig = createToolConfig('get_node_configuration_examples', 'test-3');
|
||||
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue(
|
||||
createMockFetchResult([
|
||||
createMockWorkflow('Multi-version Workflow', [
|
||||
createNode({
|
||||
id: 'http-v1',
|
||||
name: 'HTTP V1',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
parameters: { url: 'https://v1.example.com' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'http-v2',
|
||||
name: 'HTTP V2',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 2,
|
||||
parameters: { url: 'https://v2.example.com' },
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await configTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.httpRequest', nodeVersion: 2 }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'Node Configuration Examples');
|
||||
expect(message).toContain('https://v2.example.com');
|
||||
expect(message).not.toContain('https://v1.example.com');
|
||||
});
|
||||
|
||||
it('should return no examples message when node not found', async () => {
|
||||
const mockConfig = createToolConfig('get_node_configuration_examples', 'test-4');
|
||||
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue(createMockFetchResult([]));
|
||||
|
||||
const result = await configTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.unknownNode' }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'No examples found');
|
||||
expect(message).toContain('unknownNode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection examples', () => {
|
||||
let connectionTool: ReturnType<typeof createGetNodeConnectionExamplesTool>['tool'];
|
||||
|
||||
beforeEach(() => {
|
||||
connectionTool = createGetNodeConnectionExamplesTool().tool;
|
||||
});
|
||||
|
||||
it('should fetch connection examples with mermaid diagrams', async () => {
|
||||
const mockConfig = createToolConfig('get_node_connection_examples', 'test-5');
|
||||
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue(
|
||||
createMockFetchResult([
|
||||
createMockWorkflow(
|
||||
'Loop Workflow',
|
||||
[
|
||||
createNode({ id: 'trigger', name: 'Start', type: 'n8n-nodes-base.manualTrigger' }),
|
||||
createNode({
|
||||
id: 'split',
|
||||
name: 'Split Batches',
|
||||
type: 'n8n-nodes-base.splitInBatches',
|
||||
}),
|
||||
createNode({ id: 'http', name: 'Process', type: 'n8n-nodes-base.httpRequest' }),
|
||||
],
|
||||
{
|
||||
Start: { main: [[{ node: 'Split Batches', type: 'main', index: 0 }]] },
|
||||
'Split Batches': { main: [[{ node: 'Process', type: 'main', index: 0 }]] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await connectionTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.splitInBatches' }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'Node Connection Examples');
|
||||
expect(message).toContain('```mermaid');
|
||||
expect(message).toContain('flowchart TD');
|
||||
expect(message).toContain('splitInBatches');
|
||||
});
|
||||
|
||||
it('should use cached templates for connection examples', async () => {
|
||||
const mockConfig = createToolConfig('get_node_connection_examples', 'test-6');
|
||||
|
||||
mockGetCurrentTaskInput.mockReturnValue({
|
||||
cachedTemplates: [
|
||||
createMockWorkflow('Cached Connection', [
|
||||
createNode({ id: 'if', name: 'Check', type: 'n8n-nodes-base.if' }),
|
||||
createNode({ id: 'code', name: 'Process', type: 'n8n-nodes-base.code' }),
|
||||
]),
|
||||
],
|
||||
workflowJSON: { nodes: [], connections: {}, name: 'Test' },
|
||||
messages: [],
|
||||
});
|
||||
|
||||
const result = await connectionTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.if' }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'Node Connection Examples');
|
||||
expect(message).toContain('```mermaid');
|
||||
expect(mockFetchWorkflowsFromTemplates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return no examples message when no workflows found', async () => {
|
||||
const mockConfig = createToolConfig('get_node_connection_examples', 'test-7');
|
||||
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue(createMockFetchResult([]));
|
||||
|
||||
const result = await connectionTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.unknownNode' }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
expectToolSuccess(content, 'No connection examples found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch processing with local cache', () => {
|
||||
it('should use templates from earlier fetches for subsequent nodes in same batch', async () => {
|
||||
const configTool = createGetNodeConfigurationExamplesTool().tool;
|
||||
const mockConfig = createToolConfig('get_node_configuration_examples', 'test-batch');
|
||||
|
||||
// First fetch returns a workflow containing BOTH httpRequest AND code nodes
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValueOnce(
|
||||
createMockFetchResult([
|
||||
createMockWorkflow('Multi-Node Workflow', [
|
||||
createNode({
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
parameters: { url: 'https://api.example.com' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'code-1',
|
||||
name: 'Transform',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 1,
|
||||
parameters: { jsCode: 'return items.map(i => i);' },
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
// Request examples for both nodes in one call
|
||||
const result = await configTool.invoke(
|
||||
{
|
||||
nodes: [{ nodeType: 'n8n-nodes-base.httpRequest' }, { nodeType: 'n8n-nodes-base.code' }],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'Node Configuration Examples');
|
||||
// Should contain examples for both nodes
|
||||
expect(message).toContain('httpRequest');
|
||||
expect(message).toContain('https://api.example.com');
|
||||
expect(message).toContain('code');
|
||||
expect(message).toContain('return items.map');
|
||||
|
||||
// API should only be called ONCE - second node should use local cache
|
||||
expect(mockFetchWorkflowsFromTemplates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state updates', () => {
|
||||
// Extended type for state updates that include cachedTemplates
|
||||
interface ParsedToolContentWithState extends ParsedToolContent {
|
||||
update: ParsedToolContent['update'] & {
|
||||
cachedTemplates?: WorkflowMetadata[];
|
||||
};
|
||||
}
|
||||
|
||||
it('should cache new templates in state when fetched from API', async () => {
|
||||
const configTool = createGetNodeConfigurationExamplesTool().tool;
|
||||
const mockConfig = createToolConfig('get_node_configuration_examples', 'test-8');
|
||||
|
||||
const fetchedWorkflows = [
|
||||
createMockWorkflow('New Workflow', [
|
||||
createNode({
|
||||
id: 'set-1',
|
||||
name: 'Set Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
parameters: { mode: 'manual' },
|
||||
}),
|
||||
]),
|
||||
];
|
||||
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue(createMockFetchResult(fetchedWorkflows));
|
||||
|
||||
const result = await configTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.set' }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContentWithState>(result);
|
||||
|
||||
expectToolSuccess(content, 'Node Configuration Examples');
|
||||
// Verify state update includes cached templates
|
||||
expect(content.update.cachedTemplates).toEqual(fetchedWorkflows);
|
||||
});
|
||||
|
||||
it('should not update state when using cached templates', async () => {
|
||||
const configTool = createGetNodeConfigurationExamplesTool().tool;
|
||||
const mockConfig = createToolConfig('get_node_configuration_examples', 'test-9');
|
||||
|
||||
mockGetCurrentTaskInput.mockReturnValue({
|
||||
cachedTemplates: [
|
||||
createMockWorkflow('Already Cached', [
|
||||
createNode({ id: 'merge', name: 'Merge', type: 'n8n-nodes-base.merge' }),
|
||||
]),
|
||||
],
|
||||
workflowJSON: { nodes: [], connections: {}, name: 'Test' },
|
||||
messages: [],
|
||||
});
|
||||
|
||||
const result = await configTool.invoke(
|
||||
{ nodes: [{ nodeType: 'n8n-nodes-base.merge' }] },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContentWithState>(result);
|
||||
|
||||
expectToolSuccess(content, 'Node Configuration Examples');
|
||||
// No cachedTemplates in state update since we used existing cache
|
||||
expect(content.update.cachedTemplates).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+77
-103
@@ -10,8 +10,9 @@ import {
|
||||
type ParsedToolContent,
|
||||
createNode,
|
||||
} from '../../../test/test-utils';
|
||||
import type { TemplateWorkflowDescription, TemplateFetchResponse } from '../../types/web/templates';
|
||||
import type { WorkflowMetadata } from '../../types/tools';
|
||||
import { createGetWorkflowExamplesTool } from '../get-workflow-examples.tool';
|
||||
import type { FetchWorkflowsResult } from '../web/templates';
|
||||
import * as templates from '../web/templates';
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
@@ -28,12 +29,10 @@ jest.mock('../web/templates');
|
||||
|
||||
describe('GetWorkflowExamplesTool', () => {
|
||||
let getWorkflowExamplesTool: ReturnType<typeof createGetWorkflowExamplesTool>['tool'];
|
||||
const mockFetchTemplateList = templates.fetchTemplateList as jest.MockedFunction<
|
||||
typeof templates.fetchTemplateList
|
||||
>;
|
||||
const mockFetchTemplateByID = templates.fetchTemplateByID as jest.MockedFunction<
|
||||
typeof templates.fetchTemplateByID
|
||||
>;
|
||||
const mockFetchWorkflowsFromTemplates =
|
||||
templates.fetchWorkflowsFromTemplates as jest.MockedFunction<
|
||||
typeof templates.fetchWorkflowsFromTemplates
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -55,35 +54,16 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to create mock template workflow description
|
||||
const createMockTemplateDescription = (
|
||||
id: number,
|
||||
// Helper to create mock workflow metadata
|
||||
let mockTemplateIdCounter = 1;
|
||||
const createMockWorkflowMetadata = (
|
||||
name: string,
|
||||
description: string,
|
||||
): TemplateWorkflowDescription => ({
|
||||
id,
|
||||
nodeCount: number = 3,
|
||||
): WorkflowMetadata => ({
|
||||
templateId: mockTemplateIdCounter++,
|
||||
name,
|
||||
description,
|
||||
price: 0,
|
||||
totalViews: 100,
|
||||
nodes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
verified: true,
|
||||
bio: 'Test bio',
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to create mock template fetch response
|
||||
const createMockTemplateFetchResponse = (
|
||||
id: number,
|
||||
name: string,
|
||||
nodeCount: number = 3,
|
||||
): TemplateFetchResponse => ({
|
||||
id,
|
||||
name,
|
||||
workflow: {
|
||||
nodes: createMockWorkflowNodes(nodeCount),
|
||||
connections: {},
|
||||
@@ -91,22 +71,27 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to create mock fetch result
|
||||
const createMockFetchResult = (
|
||||
workflows: WorkflowMetadata[],
|
||||
templateIds: number[] = workflows.map((_, i) => i + 1),
|
||||
): FetchWorkflowsResult => ({
|
||||
workflows,
|
||||
totalFound: workflows.length,
|
||||
templateIds,
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('should successfully fetch workflow examples with search query', async () => {
|
||||
const mockConfig = createToolConfigWithWriter('get_workflow_examples', 'test-call-1');
|
||||
|
||||
// Mock API responses
|
||||
mockFetchTemplateList.mockResolvedValue({
|
||||
workflows: [
|
||||
createMockTemplateDescription(1, 'Email Automation', 'Automate email workflows'),
|
||||
createMockTemplateDescription(2, 'Slack Notification', 'Send Slack notifications'),
|
||||
],
|
||||
totalWorkflows: 2,
|
||||
});
|
||||
|
||||
mockFetchTemplateByID
|
||||
.mockResolvedValueOnce(createMockTemplateFetchResponse(1, 'Email Automation', 3))
|
||||
.mockResolvedValueOnce(createMockTemplateFetchResponse(2, 'Slack Notification', 4));
|
||||
// Mock API response
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue(
|
||||
createMockFetchResult([
|
||||
createMockWorkflowMetadata('Email Automation', 'Automate email workflows', 3),
|
||||
createMockWorkflowMetadata('Slack Notification', 'Send Slack notifications', 4),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
@@ -127,10 +112,11 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
expect(message).toContain('```mermaid');
|
||||
expect(message).toContain('flowchart TD');
|
||||
|
||||
// Verify API calls
|
||||
expect(mockFetchTemplateList).toHaveBeenCalledWith({ search: 'email automation' });
|
||||
expect(mockFetchTemplateByID).toHaveBeenCalledWith(1);
|
||||
expect(mockFetchTemplateByID).toHaveBeenCalledWith(2);
|
||||
// Verify API call
|
||||
expect(mockFetchWorkflowsFromTemplates).toHaveBeenCalledWith(
|
||||
{ search: 'email automation' },
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// Check progress messages
|
||||
const progressCalls = extractProgressMessages(mockConfig.writer);
|
||||
@@ -147,19 +133,13 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
const mockConfig = createToolConfig('get_workflow_examples', 'test-call-3');
|
||||
|
||||
// Set up mocks for two queries
|
||||
mockFetchTemplateList
|
||||
.mockResolvedValueOnce({
|
||||
workflows: [createMockTemplateDescription(1, 'Workflow 1', 'Description 1')],
|
||||
totalWorkflows: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workflows: [createMockTemplateDescription(2, 'Workflow 2', 'Description 2')],
|
||||
totalWorkflows: 1,
|
||||
});
|
||||
|
||||
mockFetchTemplateByID
|
||||
.mockResolvedValueOnce(createMockTemplateFetchResponse(1, 'Workflow 1', 2))
|
||||
.mockResolvedValueOnce(createMockTemplateFetchResponse(2, 'Workflow 2', 3));
|
||||
mockFetchWorkflowsFromTemplates
|
||||
.mockResolvedValueOnce(
|
||||
createMockFetchResult([createMockWorkflowMetadata('Workflow 1', 'Description 1', 2)]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockFetchResult([createMockWorkflowMetadata('Workflow 2', 'Description 2', 3)]),
|
||||
);
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
@@ -175,17 +155,26 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
expect(message).toContain('Workflow 1');
|
||||
expect(message).toContain('Workflow 2');
|
||||
|
||||
expect(mockFetchTemplateList).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchTemplateList).toHaveBeenNthCalledWith(1, { search: 'database' });
|
||||
expect(mockFetchTemplateList).toHaveBeenNthCalledWith(2, { search: 'api' });
|
||||
expect(mockFetchWorkflowsFromTemplates).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchWorkflowsFromTemplates).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ search: 'database' },
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockFetchWorkflowsFromTemplates).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ search: 'api' },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return no results message when no workflows found', async () => {
|
||||
const mockConfig = createToolConfig('get_workflow_examples', 'test-call-4');
|
||||
|
||||
mockFetchTemplateList.mockResolvedValue({
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue({
|
||||
workflows: [],
|
||||
totalWorkflows: 0,
|
||||
totalFound: 0,
|
||||
templateIds: [],
|
||||
});
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
@@ -203,20 +192,13 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
it('should handle partial failures when fetching individual templates', async () => {
|
||||
const mockConfig = createToolConfig('get_workflow_examples', 'test-call-5');
|
||||
|
||||
mockFetchTemplateList.mockResolvedValue({
|
||||
workflows: [
|
||||
createMockTemplateDescription(1, 'Workflow 1', 'Description 1'),
|
||||
createMockTemplateDescription(2, 'Workflow 2', 'Description 2'),
|
||||
createMockTemplateDescription(3, 'Workflow 3', 'Description 3'),
|
||||
],
|
||||
totalWorkflows: 3,
|
||||
});
|
||||
|
||||
// First succeeds, second fails, third succeeds
|
||||
mockFetchTemplateByID
|
||||
.mockResolvedValueOnce(createMockTemplateFetchResponse(1, 'Workflow 1', 2))
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce(createMockTemplateFetchResponse(3, 'Workflow 3', 3));
|
||||
// Mock returns 2 workflows (simulating one failed internally)
|
||||
mockFetchWorkflowsFromTemplates.mockResolvedValue(
|
||||
createMockFetchResult([
|
||||
createMockWorkflowMetadata('Workflow 1', 'Description 1', 2),
|
||||
createMockWorkflowMetadata('Workflow 3', 'Description 3', 3),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
@@ -232,7 +214,6 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
expectToolSuccess(content, 'Found 2 workflow example(s)');
|
||||
expect(message).toContain('Workflow 1');
|
||||
expect(message).toContain('Workflow 3');
|
||||
expect(message).not.toContain('Workflow 2');
|
||||
});
|
||||
|
||||
it('should handle validation errors for empty queries array', async () => {
|
||||
@@ -256,7 +237,7 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
it('should handle network errors when fetching template list', async () => {
|
||||
const mockConfig = createToolConfig('get_workflow_examples', 'test-call-8');
|
||||
|
||||
mockFetchTemplateList.mockRejectedValue(new Error('Network error'));
|
||||
mockFetchWorkflowsFromTemplates.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
@@ -274,19 +255,13 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
it('should track batch progress for multiple queries', async () => {
|
||||
const mockConfig = createToolConfigWithWriter('get_workflow_examples', 'test-call-11');
|
||||
|
||||
mockFetchTemplateList
|
||||
.mockResolvedValueOnce({
|
||||
workflows: [createMockTemplateDescription(1, 'Workflow 1', 'Description 1')],
|
||||
totalWorkflows: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workflows: [createMockTemplateDescription(2, 'Workflow 2', 'Description 2')],
|
||||
totalWorkflows: 1,
|
||||
});
|
||||
|
||||
mockFetchTemplateByID.mockResolvedValue(
|
||||
createMockTemplateFetchResponse(1, 'Mock Workflow', 2),
|
||||
);
|
||||
mockFetchWorkflowsFromTemplates
|
||||
.mockResolvedValueOnce(
|
||||
createMockFetchResult([createMockWorkflowMetadata('Workflow 1', 'Description 1', 2)]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockFetchResult([createMockWorkflowMetadata('Workflow 2', 'Description 2', 3)]),
|
||||
);
|
||||
|
||||
await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
@@ -318,14 +293,13 @@ describe('GetWorkflowExamplesTool', () => {
|
||||
const mockConfig = createToolConfig('get_workflow_examples', 'test-call-13');
|
||||
|
||||
// First query fails, second succeeds
|
||||
mockFetchTemplateList.mockRejectedValueOnce(new Error('API error')).mockResolvedValueOnce({
|
||||
workflows: [createMockTemplateDescription(1, 'Success Workflow', 'Success Description')],
|
||||
totalWorkflows: 1,
|
||||
});
|
||||
|
||||
mockFetchTemplateByID.mockResolvedValue(
|
||||
createMockTemplateFetchResponse(1, 'Success Workflow', 2),
|
||||
);
|
||||
mockFetchWorkflowsFromTemplates
|
||||
.mockRejectedValueOnce(new Error('API error'))
|
||||
.mockResolvedValueOnce(
|
||||
createMockFetchResult([
|
||||
createMockWorkflowMetadata('Success Workflow', 'Success Description', 2),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
@@ -14,7 +15,9 @@ import {
|
||||
expectXMLTag,
|
||||
type ParsedToolContent,
|
||||
createNodeType,
|
||||
createNode,
|
||||
} from '../../../test/test-utils';
|
||||
import type { WorkflowMetadata } from '../../types/tools';
|
||||
import { createNodeDetailsTool } from '../node-details.tool';
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
@@ -25,6 +28,19 @@ jest.mock('@langchain/langgraph', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
|
||||
// Mock the templates module to prevent actual API calls
|
||||
jest.mock('../web/templates', () => ({
|
||||
fetchWorkflowsFromTemplates: jest.fn().mockResolvedValue({
|
||||
workflows: [],
|
||||
totalFound: 0,
|
||||
templateIds: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('NodeDetailsTool', () => {
|
||||
let nodeTypesList: INodeTypeDescription[];
|
||||
let nodeDetailsTool: ReturnType<typeof createNodeDetailsTool>['tool'];
|
||||
@@ -606,5 +622,117 @@ describe('NodeDetailsTool', () => {
|
||||
description: 'Node that supports versions 1, 2, and 3',
|
||||
});
|
||||
});
|
||||
|
||||
describe('cached templates', () => {
|
||||
// Helper to create mock cached templates with specific node configurations
|
||||
let mockTemplateIdCounter = 1;
|
||||
const createMockCachedTemplate = (
|
||||
name: string,
|
||||
nodes: Array<ReturnType<typeof createNode>>,
|
||||
): WorkflowMetadata => ({
|
||||
templateId: mockTemplateIdCounter++,
|
||||
name,
|
||||
description: `Template: ${name}`,
|
||||
workflow: {
|
||||
nodes,
|
||||
connections: {},
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
it('should retrieve node examples from cached templates', async () => {
|
||||
const mockConfig = createToolConfig('get_node_details', 'test-cached-1');
|
||||
|
||||
// Create cached templates containing HTTP Request nodes with different configurations
|
||||
const cachedTemplates: WorkflowMetadata[] = [
|
||||
createMockCachedTemplate('API Integration Workflow', [
|
||||
createNode({
|
||||
id: 'http-1',
|
||||
name: 'Fetch User Data',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
parameters: {
|
||||
url: 'https://api.example.com/users',
|
||||
method: 'GET',
|
||||
authentication: 'genericCredentialType',
|
||||
},
|
||||
}),
|
||||
createNode({
|
||||
id: 'code-1',
|
||||
name: 'Process Data',
|
||||
type: 'n8n-nodes-base.code',
|
||||
}),
|
||||
]),
|
||||
createMockCachedTemplate('Webhook Handler', [
|
||||
createNode({
|
||||
id: 'http-2',
|
||||
name: 'Post to Slack',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
parameters: {
|
||||
url: 'https://hooks.slack.com/services/xxx',
|
||||
method: 'POST',
|
||||
bodyParameters: {
|
||||
parameters: [{ name: 'text', value: 'Hello World' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
];
|
||||
|
||||
// Mock getCurrentTaskInput to return state with cached templates
|
||||
mockGetCurrentTaskInput.mockReturnValue({
|
||||
cachedTemplates,
|
||||
workflowJSON: { nodes: [], connections: {}, name: 'Test' },
|
||||
messages: [],
|
||||
});
|
||||
|
||||
const result = await nodeDetailsTool.invoke(
|
||||
buildNodeDetailsInput({
|
||||
nodeName: 'n8n-nodes-base.httpRequest',
|
||||
nodeVersion: 1,
|
||||
}),
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, '<node_details>');
|
||||
|
||||
expect(message).toEqual(`<node_details>
|
||||
<name>n8n-nodes-base.httpRequest</name>
|
||||
<display_name>HTTP Request</display_name>
|
||||
<description>Test node description</description>
|
||||
<connections>
|
||||
<input>main</input>
|
||||
<output>main</output>
|
||||
</connections>
|
||||
<node_examples>
|
||||
<example>
|
||||
{
|
||||
"url": "https://api.example.com/users",
|
||||
"method": "GET",
|
||||
"authentication": "genericCredentialType"
|
||||
}
|
||||
</example>
|
||||
<example>
|
||||
{
|
||||
"url": "https://hooks.slack.com/services/xxx",
|
||||
"method": "POST",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "text",
|
||||
"value": "Hello World"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
</example>
|
||||
</node_examples>
|
||||
</node_details>`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import { MAX_NODE_EXAMPLE_CHARS } from '@/constants';
|
||||
import type { NodeConfigurationsMap, WorkflowMetadata } from '@/types';
|
||||
|
||||
/**
|
||||
* Options for mermaid diagram generation
|
||||
*/
|
||||
export interface MermaidOptions {
|
||||
/** Include node type in comments (default: true) */
|
||||
includeNodeType?: boolean;
|
||||
/** Include node parameters in comments (default: true) */
|
||||
includeNodeParameters?: boolean;
|
||||
/** Include node name in node definition (default: true) */
|
||||
includeNodeName?: boolean;
|
||||
/** Collect node configurations while processing (default: false) */
|
||||
collectNodeConfigurations?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of mermaid stringification with optional node configurations
|
||||
*/
|
||||
export interface MermaidResult {
|
||||
mermaid: string;
|
||||
nodeConfigurations: NodeConfigurationsMap;
|
||||
}
|
||||
|
||||
const DEFAULT_MERMAID_OPTIONS: Required<MermaidOptions> = {
|
||||
includeNodeType: true,
|
||||
includeNodeParameters: true,
|
||||
includeNodeName: true,
|
||||
collectNodeConfigurations: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Result from buildMermaidLines including collected configurations
|
||||
*/
|
||||
interface BuildMermaidResult {
|
||||
lines: string[];
|
||||
nodeConfigurations: NodeConfigurationsMap;
|
||||
}
|
||||
|
||||
type WorkflowNode = WorkflowMetadata['workflow']['nodes'][number];
|
||||
type WorkflowConnections = WorkflowMetadata['workflow']['connections'];
|
||||
|
||||
/**
|
||||
* Create a mapping of node names to short IDs (n1, n2, n3...)
|
||||
*/
|
||||
function createNodeIdMap(nodes: WorkflowNode[]): Map<string, string> {
|
||||
const nodeIdMap = new Map<string, string>();
|
||||
nodes.forEach((node, idx) => {
|
||||
nodeIdMap.set(node.name, `n${idx + 1}`);
|
||||
});
|
||||
return nodeIdMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all nodes that have incoming main connections
|
||||
*/
|
||||
function findNodesWithIncomingConnections(connections: WorkflowConnections): Set<string> {
|
||||
const nodesWithIncoming = new Set<string>();
|
||||
Object.values(connections)
|
||||
.filter((conn) => conn.main)
|
||||
.forEach((sourceConnections) => {
|
||||
for (const connArray of sourceConnections.main) {
|
||||
if (!connArray) {
|
||||
continue;
|
||||
}
|
||||
for (const conn of connArray) {
|
||||
nodesWithIncoming.add(conn.node);
|
||||
}
|
||||
}
|
||||
});
|
||||
return nodesWithIncoming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a connection line for mermaid output
|
||||
*/
|
||||
function formatConnectionLine(sourceId: string, targetId: string, connType: string): string {
|
||||
return connType === 'main'
|
||||
? ` ${sourceId} --> ${targetId}`
|
||||
: ` ${sourceId} -.${connType}.-> ${targetId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all connection lines from a node's connections
|
||||
*/
|
||||
function extractNodeConnectionLines(
|
||||
nodeConns: WorkflowConnections[string],
|
||||
sourceId: string,
|
||||
nodeIdMap: Map<string, string>,
|
||||
): string[] {
|
||||
return Object.entries(nodeConns).flatMap(([connType, connList]) =>
|
||||
connList
|
||||
.filter((connArray): connArray is NonNullable<typeof connArray> => connArray !== null)
|
||||
.flatMap((connArray) =>
|
||||
connArray
|
||||
.map((conn) => {
|
||||
const targetId = nodeIdMap.get(conn.node);
|
||||
return targetId ? formatConnectionLine(sourceId, targetId, connType) : null;
|
||||
})
|
||||
.filter((line): line is string => line !== null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all target node names from main connections
|
||||
*/
|
||||
function getMainConnectionTargets(nodeConns: WorkflowConnections[string]): string[] {
|
||||
if (!nodeConns.main) return [];
|
||||
return nodeConns.main
|
||||
.filter((connArray): connArray is NonNullable<typeof connArray> => connArray !== null)
|
||||
.flatMap((connArray) => connArray.map((conn) => conn.node));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build connection lines by traversing the workflow graph
|
||||
*/
|
||||
function buildConnectionLines(
|
||||
connections: WorkflowConnections,
|
||||
nodeIdMap: Map<string, string>,
|
||||
startNodes: WorkflowNode[],
|
||||
): string[] {
|
||||
const visited = new Set<string>();
|
||||
const outputConnections: string[] = [];
|
||||
|
||||
function traverse(nodeName: string) {
|
||||
if (visited.has(nodeName)) return;
|
||||
visited.add(nodeName);
|
||||
|
||||
const nodeConns = connections[nodeName];
|
||||
if (!nodeConns) return;
|
||||
|
||||
const sourceId = nodeIdMap.get(nodeName);
|
||||
if (!sourceId) return;
|
||||
|
||||
outputConnections.push(...extractNodeConnectionLines(nodeConns, sourceId, nodeIdMap));
|
||||
getMainConnectionTargets(nodeConns).forEach((target) => traverse(target));
|
||||
}
|
||||
|
||||
startNodes.forEach((node) => traverse(node.name));
|
||||
return outputConnections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect node configuration if it meets size requirements
|
||||
*/
|
||||
function maybeCollectNodeConfiguration(
|
||||
node: WorkflowNode,
|
||||
nodeConfigurations: NodeConfigurationsMap,
|
||||
): void {
|
||||
const hasParams = Object.keys(node.parameters).length > 0;
|
||||
if (!hasParams) return;
|
||||
|
||||
const parametersStr = JSON.stringify(node.parameters);
|
||||
if (parametersStr.length <= MAX_NODE_EXAMPLE_CHARS) {
|
||||
if (!nodeConfigurations[node.type]) {
|
||||
nodeConfigurations[node.type] = [];
|
||||
}
|
||||
nodeConfigurations[node.type].push({
|
||||
version: node.typeVersion,
|
||||
parameters: node.parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build node definition lines (comments and node declarations)
|
||||
*/
|
||||
function buildNodeDefinitionLines(
|
||||
nodes: WorkflowNode[],
|
||||
nodeIdMap: Map<string, string>,
|
||||
options: Required<MermaidOptions>,
|
||||
nodeConfigurations: NodeConfigurationsMap,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const id = nodeIdMap.get(node.name);
|
||||
if (!id) continue;
|
||||
|
||||
const hasParams = Object.keys(node.parameters).length > 0;
|
||||
|
||||
if (options.collectNodeConfigurations) {
|
||||
maybeCollectNodeConfiguration(node, nodeConfigurations);
|
||||
}
|
||||
|
||||
if (options.includeNodeType || options.includeNodeParameters) {
|
||||
const typePart = options.includeNodeType ? node.type : '';
|
||||
const paramsPart =
|
||||
options.includeNodeParameters && hasParams ? ` | ${JSON.stringify(node.parameters)}` : '';
|
||||
|
||||
if (typePart || paramsPart) {
|
||||
lines.push(` %% ${typePart}${paramsPart}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeNodeName) {
|
||||
lines.push(` ${id}["${node.name.replace(/"/g, "'")}"]`);
|
||||
} else {
|
||||
lines.push(` ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Mermaid flowchart from workflow nodes and connections
|
||||
*/
|
||||
function buildMermaidLines(
|
||||
nodes: WorkflowMetadata['workflow']['nodes'],
|
||||
connections: WorkflowConnections,
|
||||
options: Required<MermaidOptions> = DEFAULT_MERMAID_OPTIONS,
|
||||
existingConfigurations?: NodeConfigurationsMap,
|
||||
): BuildMermaidResult {
|
||||
const regularNodes = nodes.filter((n) => n.type !== 'n8n-nodes-base.stickyNote');
|
||||
const nodeConfigurations: NodeConfigurationsMap = existingConfigurations ?? {};
|
||||
|
||||
const nodeIdMap = createNodeIdMap(regularNodes);
|
||||
const nodesWithIncoming = findNodesWithIncomingConnections(connections);
|
||||
const startNodes = regularNodes.filter((n) => !nodesWithIncoming.has(n.name));
|
||||
|
||||
const connectionLines = buildConnectionLines(connections, nodeIdMap, startNodes);
|
||||
const nodeDefinitionLines = buildNodeDefinitionLines(
|
||||
regularNodes,
|
||||
nodeIdMap,
|
||||
options,
|
||||
nodeConfigurations,
|
||||
);
|
||||
|
||||
const lines = ['```mermaid', 'flowchart TD', ...nodeDefinitionLines, ...connectionLines, '```'];
|
||||
|
||||
return { lines, nodeConfigurations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Mermaid flowchart diagram from a workflow
|
||||
*/
|
||||
export function mermaidStringify(workflow: WorkflowMetadata, options?: MermaidOptions): string {
|
||||
const { workflow: wf } = workflow;
|
||||
const mergedOptions: Required<MermaidOptions> = {
|
||||
...DEFAULT_MERMAID_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
const result = buildMermaidLines(wf.nodes, wf.connections, mergedOptions);
|
||||
return result.lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple workflows and generate mermaid diagrams while collecting node configurations
|
||||
* This is more efficient than calling mermaidStringify and extractNodeConfigurations separately
|
||||
*/
|
||||
export function processWorkflowExamples(
|
||||
workflows: WorkflowMetadata[],
|
||||
options?: Omit<MermaidOptions, 'collectNodeConfigurations'>,
|
||||
): MermaidResult[] {
|
||||
const mergedOptions: Required<MermaidOptions> = {
|
||||
...DEFAULT_MERMAID_OPTIONS,
|
||||
...options,
|
||||
collectNodeConfigurations: true,
|
||||
};
|
||||
|
||||
// Accumulate configurations across all workflows
|
||||
const allConfigurations: NodeConfigurationsMap = {};
|
||||
|
||||
const results: MermaidResult[] = workflows.map((workflow) => {
|
||||
const { workflow: wf } = workflow;
|
||||
const result = buildMermaidLines(wf.nodes, wf.connections, mergedOptions, allConfigurations);
|
||||
return {
|
||||
mermaid: result.lines.join('\n'),
|
||||
nodeConfigurations: result.nodeConfigurations,
|
||||
};
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates sticky notes section from a workflow
|
||||
*/
|
||||
export function stickyNotesStringify(workflow: WorkflowMetadata): string {
|
||||
const { workflow: wf } = workflow;
|
||||
const stickyNotes = wf.nodes.filter((node) => node.type === 'n8n-nodes-base.stickyNote');
|
||||
|
||||
if (stickyNotes.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const note of stickyNotes) {
|
||||
const content = note.parameters.content;
|
||||
if (typeof content === 'string' && content) {
|
||||
// Indent continuation lines so they appear as part of the bullet
|
||||
const contentLines = content.trim().split('\n');
|
||||
const indentedContent = contentLines
|
||||
.map((line, idx) => (idx === 0 ? `- ${line}` : ` ${line}`))
|
||||
.join('\n');
|
||||
lines.push(indentedContent);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
import type { NodeConfigurationsMap, WorkflowMetadata } from '@/types';
|
||||
|
||||
import {
|
||||
collectSingleNodeConfiguration,
|
||||
addNodeConfigurationToMap,
|
||||
} from './node-configuration.utils';
|
||||
|
||||
/**
|
||||
* Options for mermaid diagram generation
|
||||
*/
|
||||
export interface MermaidOptions {
|
||||
/** Include node type in comments (default: true) */
|
||||
includeNodeType?: boolean;
|
||||
/** Include node parameters in comments (default: true) */
|
||||
includeNodeParameters?: boolean;
|
||||
/** Include node name in node definition (default: true) */
|
||||
includeNodeName?: boolean;
|
||||
/** Collect node configurations while processing (default: false) */
|
||||
collectNodeConfigurations?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of mermaid stringification with optional node configurations
|
||||
*/
|
||||
export interface MermaidResult {
|
||||
mermaid: string;
|
||||
nodeConfigurations: NodeConfigurationsMap;
|
||||
}
|
||||
|
||||
const DEFAULT_MERMAID_OPTIONS: Required<MermaidOptions> = {
|
||||
includeNodeType: true,
|
||||
includeNodeParameters: true,
|
||||
includeNodeName: true,
|
||||
collectNodeConfigurations: false,
|
||||
};
|
||||
|
||||
/** Node types that represent conditional/branching logic (rendered as diamond shape) */
|
||||
const CONDITIONAL_NODE_TYPES = new Set([
|
||||
'n8n-nodes-base.if',
|
||||
'n8n-nodes-base.switch',
|
||||
'n8n-nodes-base.filter',
|
||||
]);
|
||||
|
||||
/** Node type for AI agents that should be wrapped in subgraphs */
|
||||
const AGENT_NODE_TYPE = '@n8n/n8n-nodes-langchain.agent';
|
||||
const STICKY_NOTE_TYPE = 'n8n-nodes-base.stickyNote';
|
||||
|
||||
type WorkflowNode = WorkflowMetadata['workflow']['nodes'][number];
|
||||
type WorkflowConnections = WorkflowMetadata['workflow']['connections'];
|
||||
|
||||
/** Default node dimensions when checking sticky overlap */
|
||||
const DEFAULT_NODE_WIDTH = 100;
|
||||
const DEFAULT_NODE_HEIGHT = 100;
|
||||
|
||||
/** Default sticky dimensions */
|
||||
const DEFAULT_STICKY_WIDTH = 150;
|
||||
const DEFAULT_STICKY_HEIGHT = 80;
|
||||
|
||||
/**
|
||||
* Represents a sticky note with its bounds and content
|
||||
*/
|
||||
interface StickyBounds {
|
||||
node: WorkflowNode;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of categorizing sticky notes by their overlap with regular nodes
|
||||
*/
|
||||
interface StickyOverlapResult {
|
||||
noOverlap: StickyBounds[];
|
||||
singleNodeOverlap: Map<string, StickyBounds>;
|
||||
multiNodeOverlap: Array<{ sticky: StickyBounds; nodeNames: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an agent node with its AI-connected nodes for subgraph grouping
|
||||
*/
|
||||
interface AgentSubgraph {
|
||||
agentNode: WorkflowNode;
|
||||
aiConnectedNodeNames: string[];
|
||||
nestedStickySubgraphs: Array<{ sticky: StickyBounds; nodeNames: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder class for generating Mermaid flowchart diagrams from n8n workflows
|
||||
*/
|
||||
class MermaidBuilder {
|
||||
private readonly nodes: WorkflowNode[];
|
||||
private readonly connections: WorkflowConnections;
|
||||
private readonly options: Required<MermaidOptions>;
|
||||
private readonly nodeConfigurations: NodeConfigurationsMap;
|
||||
|
||||
private readonly nodeIdMap: Map<string, string>;
|
||||
private readonly nodeByName: Map<string, WorkflowNode>;
|
||||
private readonly stickyOverlaps: StickyOverlapResult;
|
||||
private readonly agentSubgraphs: AgentSubgraph[];
|
||||
private readonly nodesInSubgraphs: Set<string>;
|
||||
|
||||
private readonly definedNodes = new Set<string>();
|
||||
private readonly lines: string[] = [];
|
||||
private subgraphCounter = 0;
|
||||
|
||||
constructor(
|
||||
nodes: WorkflowNode[],
|
||||
connections: WorkflowConnections,
|
||||
options: Required<MermaidOptions>,
|
||||
existingConfigurations?: NodeConfigurationsMap,
|
||||
) {
|
||||
const regularNodes = nodes.filter((n) => n.type !== STICKY_NOTE_TYPE);
|
||||
const stickyNotes = nodes.filter((n) => n.type === STICKY_NOTE_TYPE);
|
||||
|
||||
this.nodes = regularNodes;
|
||||
this.connections = connections;
|
||||
this.options = options;
|
||||
this.nodeConfigurations = existingConfigurations ?? {};
|
||||
|
||||
this.nodeIdMap = this.createNodeIdMap();
|
||||
this.nodeByName = new Map(regularNodes.map((n) => [n.name, n]));
|
||||
this.stickyOverlaps = this.categorizeStickyOverlaps(stickyNotes);
|
||||
|
||||
const nodesInStickySubgraphs = new Set<string>();
|
||||
for (const { nodeNames } of this.stickyOverlaps.multiNodeOverlap) {
|
||||
for (const name of nodeNames) {
|
||||
nodesInStickySubgraphs.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
this.agentSubgraphs = this.findAgentSubgraphs(nodesInStickySubgraphs);
|
||||
|
||||
this.nodesInSubgraphs = new Set<string>(nodesInStickySubgraphs);
|
||||
for (const { agentNode, aiConnectedNodeNames } of this.agentSubgraphs) {
|
||||
this.nodesInSubgraphs.add(agentNode.name);
|
||||
for (const name of aiConnectedNodeNames) {
|
||||
this.nodesInSubgraphs.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete mermaid diagram
|
||||
*/
|
||||
build(): { lines: string[]; nodeConfigurations: NodeConfigurationsMap } {
|
||||
// Add comments for stickies that don't overlap any nodes
|
||||
for (const sticky of this.stickyOverlaps.noOverlap) {
|
||||
this.lines.push(this.formatStickyComment(sticky.content));
|
||||
}
|
||||
|
||||
// Build main flow
|
||||
this.buildMainFlow();
|
||||
|
||||
// Build subgraph sections
|
||||
this.buildStickySubgraphs();
|
||||
this.buildAgentSubgraphs();
|
||||
|
||||
// Build cross-subgraph connections
|
||||
this.buildConnectionsToSubgraphs();
|
||||
this.buildConnectionsFromSubgraphs();
|
||||
this.buildInterSubgraphConnections();
|
||||
|
||||
return {
|
||||
lines: ['```mermaid', 'flowchart TD', ...this.lines, '```'],
|
||||
nodeConfigurations: this.nodeConfigurations,
|
||||
};
|
||||
}
|
||||
|
||||
// Initialization helpers
|
||||
|
||||
private createNodeIdMap(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
this.nodes.forEach((node, idx) => {
|
||||
map.set(node.name, `n${idx + 1}`);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
private categorizeStickyOverlaps(stickyNotes: WorkflowNode[]): StickyOverlapResult {
|
||||
const result: StickyOverlapResult = {
|
||||
noOverlap: [],
|
||||
singleNodeOverlap: new Map(),
|
||||
multiNodeOverlap: [],
|
||||
};
|
||||
|
||||
for (const sticky of stickyNotes) {
|
||||
const bounds = this.extractStickyBounds(sticky);
|
||||
if (!bounds.content) continue;
|
||||
|
||||
const overlappingNodes = this.nodes.filter((node) =>
|
||||
this.isNodeWithinStickyBounds(node.position[0], node.position[1], bounds),
|
||||
);
|
||||
|
||||
if (overlappingNodes.length === 0) {
|
||||
result.noOverlap.push(bounds);
|
||||
} else if (overlappingNodes.length === 1) {
|
||||
result.singleNodeOverlap.set(overlappingNodes[0].name, bounds);
|
||||
} else {
|
||||
result.multiNodeOverlap.push({
|
||||
sticky: bounds,
|
||||
nodeNames: overlappingNodes.map((n) => n.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private extractStickyBounds(node: WorkflowNode): StickyBounds {
|
||||
return {
|
||||
node,
|
||||
x: node.position[0],
|
||||
y: node.position[1],
|
||||
width:
|
||||
typeof node.parameters.width === 'number' ? node.parameters.width : DEFAULT_STICKY_WIDTH,
|
||||
height:
|
||||
typeof node.parameters.height === 'number' ? node.parameters.height : DEFAULT_STICKY_HEIGHT,
|
||||
content: typeof node.parameters.content === 'string' ? node.parameters.content.trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
private isNodeWithinStickyBounds(nodeX: number, nodeY: number, sticky: StickyBounds): boolean {
|
||||
const nodeCenterX = nodeX + DEFAULT_NODE_WIDTH / 2;
|
||||
const nodeCenterY = nodeY + DEFAULT_NODE_HEIGHT / 2;
|
||||
return (
|
||||
nodeCenterX >= sticky.x &&
|
||||
nodeCenterX <= sticky.x + sticky.width &&
|
||||
nodeCenterY >= sticky.y &&
|
||||
nodeCenterY <= sticky.y + sticky.height
|
||||
);
|
||||
}
|
||||
|
||||
private findAgentSubgraphs(nodesInStickySubgraphs: Set<string>): AgentSubgraph[] {
|
||||
const agentSubgraphs: AgentSubgraph[] = [];
|
||||
const agentNodes = this.nodes.filter(
|
||||
(n) => n.type === AGENT_NODE_TYPE && !nodesInStickySubgraphs.has(n.name),
|
||||
);
|
||||
|
||||
const reverseConnections = this.buildReverseConnectionMap();
|
||||
|
||||
for (const agentNode of agentNodes) {
|
||||
const incomingConns = reverseConnections.get(agentNode.name) ?? [];
|
||||
|
||||
const aiConnectedNodeNames = incomingConns
|
||||
.filter(
|
||||
({ connType, sourceName }) =>
|
||||
connType !== 'main' && !nodesInStickySubgraphs.has(sourceName),
|
||||
)
|
||||
.map(({ sourceName }) => sourceName);
|
||||
|
||||
const nestedStickySubgraphs = this.findNestedStickySubgraphs(incomingConns);
|
||||
|
||||
if (aiConnectedNodeNames.length > 0 || nestedStickySubgraphs.length > 0) {
|
||||
agentSubgraphs.push({ agentNode, aiConnectedNodeNames, nestedStickySubgraphs });
|
||||
}
|
||||
}
|
||||
|
||||
return agentSubgraphs;
|
||||
}
|
||||
|
||||
private findNestedStickySubgraphs(
|
||||
incomingConns: Array<{ sourceName: string; connType: string }>,
|
||||
): Array<{ sticky: StickyBounds; nodeNames: string[] }> {
|
||||
const nested: Array<{ sticky: StickyBounds; nodeNames: string[] }> = [];
|
||||
|
||||
for (const stickySubgraph of this.stickyOverlaps.multiNodeOverlap) {
|
||||
const allNodesConnectToAgent = stickySubgraph.nodeNames.every((nodeName) =>
|
||||
incomingConns.some(
|
||||
({ sourceName, connType }) => sourceName === nodeName && connType !== 'main',
|
||||
),
|
||||
);
|
||||
if (allNodesConnectToAgent) {
|
||||
nested.push(stickySubgraph);
|
||||
}
|
||||
}
|
||||
|
||||
return nested;
|
||||
}
|
||||
|
||||
private buildReverseConnectionMap(): Map<
|
||||
string,
|
||||
Array<{ sourceName: string; connType: string }>
|
||||
> {
|
||||
const reverseConnections = new Map<string, Array<{ sourceName: string; connType: string }>>();
|
||||
|
||||
for (const [sourceName, sourceConns] of Object.entries(this.connections)) {
|
||||
for (const { nodeName: targetName, connType } of this.getConnectionTargets(sourceConns)) {
|
||||
if (!reverseConnections.has(targetName)) {
|
||||
reverseConnections.set(targetName, []);
|
||||
}
|
||||
reverseConnections.get(targetName)!.push({ sourceName, connType });
|
||||
}
|
||||
}
|
||||
|
||||
return reverseConnections;
|
||||
}
|
||||
|
||||
// Connection helpers
|
||||
|
||||
private getConnectionTargets(
|
||||
nodeConns: WorkflowConnections[string],
|
||||
): Array<{ nodeName: string; connType: string }> {
|
||||
const targets: Array<{ nodeName: string; connType: string }> = [];
|
||||
for (const [connType, connList] of Object.entries(nodeConns)) {
|
||||
for (const connArray of connList) {
|
||||
if (!connArray) continue;
|
||||
for (const conn of connArray) {
|
||||
targets.push({ nodeName: conn.node, connType });
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
private getMainConnectionTargets(nodeConns: WorkflowConnections[string]): string[] {
|
||||
if (!nodeConns.main) return [];
|
||||
return nodeConns.main
|
||||
.filter((connArray): connArray is NonNullable<typeof connArray> => connArray !== null)
|
||||
.flatMap((connArray) => connArray.map((conn) => conn.node));
|
||||
}
|
||||
|
||||
private findStartNodes(): WorkflowNode[] {
|
||||
const nodesWithIncoming = new Set<string>();
|
||||
Object.values(this.connections)
|
||||
.filter((conn) => conn.main)
|
||||
.forEach((sourceConnections) => {
|
||||
for (const connArray of sourceConnections.main) {
|
||||
if (!connArray) continue;
|
||||
for (const conn of connArray) {
|
||||
nodesWithIncoming.add(conn.node);
|
||||
}
|
||||
}
|
||||
});
|
||||
return this.nodes.filter((n) => !nodesWithIncoming.has(n.name));
|
||||
}
|
||||
|
||||
// Node definition helpers
|
||||
|
||||
private formatStickyComment(content: string): string {
|
||||
return `%% ${content.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim()}`;
|
||||
}
|
||||
|
||||
private getNextSubgraphId(): string {
|
||||
this.subgraphCounter++;
|
||||
return `sg${this.subgraphCounter}`;
|
||||
}
|
||||
|
||||
private buildNodeDefinition(node: WorkflowNode, id: string): string {
|
||||
const isConditional = CONDITIONAL_NODE_TYPES.has(node.type);
|
||||
if (this.options.includeNodeName) {
|
||||
const escapedName = node.name.replace(/"/g, "'");
|
||||
return isConditional ? `${id}{"${escapedName}"}` : `${id}["${escapedName}"]`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private buildNodeCommentLines(node: WorkflowNode): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (this.options.collectNodeConfigurations) {
|
||||
const config = collectSingleNodeConfiguration(node);
|
||||
if (config) {
|
||||
addNodeConfigurationToMap(node.type, config, this.nodeConfigurations);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.includeNodeType || this.options.includeNodeParameters) {
|
||||
const typePart = this.options.includeNodeType ? this.buildNodeTypePart(node) : '';
|
||||
const paramsPart =
|
||||
this.options.includeNodeParameters && Object.keys(node.parameters).length > 0
|
||||
? ` | ${JSON.stringify(node.parameters)}`
|
||||
: '';
|
||||
|
||||
if (typePart || paramsPart) {
|
||||
lines.push(`%% ${typePart}${paramsPart}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private buildNodeTypePart(node: WorkflowNode): string {
|
||||
const parts = [node.type];
|
||||
if (typeof node.parameters.resource === 'string' && node.parameters.resource) {
|
||||
parts.push(node.parameters.resource);
|
||||
}
|
||||
if (typeof node.parameters.operation === 'string' && node.parameters.operation) {
|
||||
parts.push(node.parameters.operation);
|
||||
}
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
private buildSingleNodeLines(node: WorkflowNode, id: string): string[] {
|
||||
const lines = this.buildNodeCommentLines(node);
|
||||
lines.push(this.buildNodeDefinition(node, id));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private defineNodeIfNeeded(nodeName: string): string {
|
||||
const node = this.nodeByName.get(nodeName);
|
||||
const id = this.nodeIdMap.get(nodeName);
|
||||
if (!node || !id) return id ?? '';
|
||||
|
||||
if (!this.definedNodes.has(nodeName)) {
|
||||
this.definedNodes.add(nodeName);
|
||||
|
||||
const stickyForNode = this.stickyOverlaps.singleNodeOverlap.get(nodeName);
|
||||
if (stickyForNode) {
|
||||
this.lines.push(this.formatStickyComment(stickyForNode.content));
|
||||
}
|
||||
|
||||
this.lines.push(...this.buildNodeCommentLines(node));
|
||||
return this.buildNodeDefinition(node, id);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines target node if not already defined, and adds connection from source.
|
||||
* Returns true if target was newly defined with a 'main' connection type.
|
||||
*/
|
||||
private defineTargetAndConnect(sourceId: string, targetName: string, connType: string): boolean {
|
||||
const targetId = this.nodeIdMap.get(targetName);
|
||||
if (!targetId) return false;
|
||||
|
||||
if (!this.definedNodes.has(targetName)) {
|
||||
const targetNode = this.nodeByName.get(targetName);
|
||||
if (targetNode) {
|
||||
const stickyForNode = this.stickyOverlaps.singleNodeOverlap.get(targetName);
|
||||
if (stickyForNode) {
|
||||
this.lines.push(this.formatStickyComment(stickyForNode.content));
|
||||
}
|
||||
this.lines.push(...this.buildNodeCommentLines(targetNode));
|
||||
this.addConnection(sourceId, this.buildNodeDefinition(targetNode, targetId), connType);
|
||||
this.definedNodes.add(targetName);
|
||||
return connType === 'main';
|
||||
}
|
||||
} else {
|
||||
this.addConnection(sourceId, targetId, connType);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private addConnection(sourceId: string, targetDef: string, connType: string): void {
|
||||
const arrow = connType === 'main' ? '-->' : `-.${connType}.->`;
|
||||
this.lines.push(`${sourceId} ${arrow} ${targetDef}`);
|
||||
}
|
||||
|
||||
// Main flow building
|
||||
|
||||
private buildMainFlow(): void {
|
||||
const visited = new Set<string>();
|
||||
const startNodes = this.findStartNodes();
|
||||
|
||||
const traverse = (nodeName: string) => {
|
||||
if (visited.has(nodeName)) return;
|
||||
visited.add(nodeName);
|
||||
|
||||
const nodeConns = this.connections[nodeName];
|
||||
const targets = nodeConns ? this.getConnectionTargets(nodeConns) : [];
|
||||
|
||||
for (const { nodeName: targetName, connType } of targets) {
|
||||
if (this.nodesInSubgraphs.has(targetName) || this.nodesInSubgraphs.has(nodeName)) continue;
|
||||
|
||||
const sourceId = this.nodeIdMap.get(nodeName);
|
||||
const targetDef = this.defineNodeIfNeeded(targetName);
|
||||
if (sourceId) {
|
||||
this.addConnection(sourceId, targetDef, connType);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeConns) {
|
||||
this.getMainConnectionTargets(nodeConns)
|
||||
.filter((target) => !this.nodesInSubgraphs.has(target))
|
||||
.forEach((target) => traverse(target));
|
||||
}
|
||||
};
|
||||
|
||||
for (const startNode of startNodes) {
|
||||
if (this.nodesInSubgraphs.has(startNode.name)) continue;
|
||||
|
||||
const id = this.nodeIdMap.get(startNode.name);
|
||||
if (id && !this.definedNodes.has(startNode.name)) {
|
||||
const stickyForNode = this.stickyOverlaps.singleNodeOverlap.get(startNode.name);
|
||||
if (stickyForNode) {
|
||||
this.lines.push(this.formatStickyComment(stickyForNode.content));
|
||||
}
|
||||
this.lines.push(...this.buildSingleNodeLines(startNode, id));
|
||||
this.definedNodes.add(startNode.name);
|
||||
}
|
||||
|
||||
traverse(startNode.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky subgraph building
|
||||
|
||||
private buildStickySubgraphs(): void {
|
||||
const nestedStickyIds = this.getNestedStickyIds();
|
||||
|
||||
for (const { sticky, nodeNames } of this.stickyOverlaps.multiNodeOverlap) {
|
||||
if (nestedStickyIds.has(sticky.node.id ?? '')) continue;
|
||||
|
||||
this.buildSingleStickySubgraph(sticky, nodeNames);
|
||||
}
|
||||
}
|
||||
|
||||
private getNestedStickyIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const { nestedStickySubgraphs } of this.agentSubgraphs) {
|
||||
for (const { sticky } of nestedStickySubgraphs) {
|
||||
ids.add(sticky.node.id ?? '');
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private buildSingleStickySubgraph(sticky: StickyBounds, nodeNames: string[]): void {
|
||||
const subgraphId = this.getNextSubgraphId();
|
||||
const subgraphLabel = sticky.content.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
this.lines.push(this.formatStickyComment(sticky.content));
|
||||
this.lines.push(`subgraph ${subgraphId}["${subgraphLabel.replace(/"/g, "'")}"]`);
|
||||
|
||||
const subgraphNodeSet = new Set(nodeNames);
|
||||
const subgraphDefinedNodes = new Set<string>();
|
||||
|
||||
// Find and define start nodes
|
||||
const startNodes = this.findSubgraphStartNodes(nodeNames, subgraphNodeSet);
|
||||
for (const startNode of startNodes) {
|
||||
const id = this.nodeIdMap.get(startNode.name);
|
||||
if (id && !subgraphDefinedNodes.has(startNode.name)) {
|
||||
this.lines.push(...this.buildSingleNodeLines(startNode, id));
|
||||
subgraphDefinedNodes.add(startNode.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build internal connections
|
||||
this.buildSubgraphInternalConnections(startNodes, subgraphNodeSet, subgraphDefinedNodes);
|
||||
|
||||
// Mark all as defined
|
||||
for (const name of nodeNames) {
|
||||
this.definedNodes.add(name);
|
||||
}
|
||||
|
||||
this.lines.push('end');
|
||||
}
|
||||
|
||||
private findSubgraphStartNodes(
|
||||
nodeNames: string[],
|
||||
subgraphNodeSet: Set<string>,
|
||||
): WorkflowNode[] {
|
||||
const nodesWithInternalIncoming = new Set<string>();
|
||||
|
||||
for (const nodeName of nodeNames) {
|
||||
const nodeConns = this.connections[nodeName];
|
||||
if (!nodeConns) continue;
|
||||
|
||||
for (const { nodeName: targetName } of this.getConnectionTargets(nodeConns)) {
|
||||
if (subgraphNodeSet.has(targetName)) {
|
||||
nodesWithInternalIncoming.add(targetName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodeNames
|
||||
.filter((name) => !nodesWithInternalIncoming.has(name))
|
||||
.map((name) => this.nodeByName.get(name))
|
||||
.filter((node): node is WorkflowNode => node !== undefined);
|
||||
}
|
||||
|
||||
private buildSubgraphInternalConnections(
|
||||
startNodes: WorkflowNode[],
|
||||
subgraphNodeSet: Set<string>,
|
||||
subgraphDefinedNodes: Set<string>,
|
||||
): void {
|
||||
const visited = new Set<string>();
|
||||
|
||||
const traverse = (nodeName: string) => {
|
||||
if (visited.has(nodeName)) return;
|
||||
visited.add(nodeName);
|
||||
|
||||
const nodeConns = this.connections[nodeName];
|
||||
if (!nodeConns) return;
|
||||
|
||||
const sourceId = this.nodeIdMap.get(nodeName);
|
||||
if (!sourceId) return;
|
||||
|
||||
for (const { nodeName: targetName, connType } of this.getConnectionTargets(nodeConns)) {
|
||||
if (!subgraphNodeSet.has(targetName)) continue;
|
||||
|
||||
const targetId = this.nodeIdMap.get(targetName);
|
||||
const targetNode = this.nodeByName.get(targetName);
|
||||
if (!targetId || !targetNode) continue;
|
||||
|
||||
const arrow = connType === 'main' ? '-->' : `-.${connType}.->`;
|
||||
|
||||
if (!subgraphDefinedNodes.has(targetName)) {
|
||||
this.lines.push(...this.buildNodeCommentLines(targetNode));
|
||||
this.lines.push(`${sourceId} ${arrow} ${this.buildNodeDefinition(targetNode, targetId)}`);
|
||||
subgraphDefinedNodes.add(targetName);
|
||||
} else {
|
||||
this.lines.push(`${sourceId} ${arrow} ${targetId}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.getMainConnectionTargets(nodeConns)
|
||||
.filter((t) => subgraphNodeSet.has(t))
|
||||
.forEach((t) => traverse(t));
|
||||
};
|
||||
|
||||
startNodes.forEach((n) => traverse(n.name));
|
||||
}
|
||||
|
||||
// Agent subgraph building
|
||||
|
||||
private buildAgentSubgraphs(): void {
|
||||
for (const agentSubgraph of this.agentSubgraphs) {
|
||||
this.buildSingleAgentSubgraph(agentSubgraph);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSingleAgentSubgraph(agentSubgraph: AgentSubgraph): void {
|
||||
const { agentNode, aiConnectedNodeNames, nestedStickySubgraphs } = agentSubgraph;
|
||||
const agentId = this.nodeIdMap.get(agentNode.name);
|
||||
if (!agentId) return;
|
||||
|
||||
const subgraphId = this.getNextSubgraphId();
|
||||
this.lines.push(`subgraph ${subgraphId}["${agentNode.name.replace(/"/g, "'")}"]`);
|
||||
|
||||
// Define direct AI-connected nodes
|
||||
for (const nodeName of aiConnectedNodeNames) {
|
||||
this.defineAgentConnectedNode(nodeName);
|
||||
}
|
||||
|
||||
// Build nested sticky subgraphs
|
||||
for (const { sticky, nodeNames } of nestedStickySubgraphs) {
|
||||
this.buildNestedStickySubgraph(sticky, nodeNames);
|
||||
}
|
||||
|
||||
// Define agent node and its connections
|
||||
this.buildAgentNodeConnections(agentNode, agentId, aiConnectedNodeNames, nestedStickySubgraphs);
|
||||
|
||||
// Mark all as defined
|
||||
this.markAgentSubgraphNodesDefined(agentNode, aiConnectedNodeNames, nestedStickySubgraphs);
|
||||
|
||||
this.lines.push('end');
|
||||
}
|
||||
|
||||
private defineAgentConnectedNode(nodeName: string): void {
|
||||
const node = this.nodeByName.get(nodeName);
|
||||
const id = this.nodeIdMap.get(nodeName);
|
||||
if (!node || !id) return;
|
||||
|
||||
const stickyForNode = this.stickyOverlaps.singleNodeOverlap.get(nodeName);
|
||||
if (stickyForNode) {
|
||||
this.lines.push(this.formatStickyComment(stickyForNode.content));
|
||||
}
|
||||
|
||||
this.lines.push(...this.buildSingleNodeLines(node, id));
|
||||
}
|
||||
|
||||
private buildNestedStickySubgraph(sticky: StickyBounds, nodeNames: string[]): void {
|
||||
const nestedSubgraphId = this.getNextSubgraphId();
|
||||
const label = sticky.content.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
this.lines.push(this.formatStickyComment(sticky.content));
|
||||
this.lines.push(`subgraph ${nestedSubgraphId}["${label.replace(/"/g, "'")}"]`);
|
||||
|
||||
for (const nodeName of nodeNames) {
|
||||
const node = this.nodeByName.get(nodeName);
|
||||
const id = this.nodeIdMap.get(nodeName);
|
||||
if (node && id) {
|
||||
this.lines.push(...this.buildSingleNodeLines(node, id));
|
||||
}
|
||||
}
|
||||
|
||||
this.lines.push('end');
|
||||
}
|
||||
|
||||
private buildAgentNodeConnections(
|
||||
agentNode: WorkflowNode,
|
||||
agentId: string,
|
||||
aiConnectedNodeNames: string[],
|
||||
nestedStickySubgraphs: Array<{ sticky: StickyBounds; nodeNames: string[] }>,
|
||||
): void {
|
||||
const stickyForAgent = this.stickyOverlaps.singleNodeOverlap.get(agentNode.name);
|
||||
if (stickyForAgent) {
|
||||
this.lines.push(this.formatStickyComment(stickyForAgent.content));
|
||||
}
|
||||
this.lines.push(...this.buildNodeCommentLines(agentNode));
|
||||
|
||||
const allAiNodeNames = [
|
||||
...aiConnectedNodeNames,
|
||||
...nestedStickySubgraphs.flatMap(({ nodeNames }) => nodeNames),
|
||||
];
|
||||
|
||||
let agentDefined = false;
|
||||
for (const nodeName of allAiNodeNames) {
|
||||
const sourceId = this.nodeIdMap.get(nodeName);
|
||||
const nodeConns = this.connections[nodeName];
|
||||
if (!sourceId || !nodeConns) continue;
|
||||
|
||||
for (const { nodeName: targetName, connType } of this.getConnectionTargets(nodeConns)) {
|
||||
if (targetName !== agentNode.name || connType === 'main') continue;
|
||||
|
||||
const arrow = `-.${connType}.->`;
|
||||
if (!agentDefined) {
|
||||
this.lines.push(`${sourceId} ${arrow} ${this.buildNodeDefinition(agentNode, agentId)}`);
|
||||
agentDefined = true;
|
||||
} else {
|
||||
this.lines.push(`${sourceId} ${arrow} ${agentId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentDefined) {
|
||||
this.lines.push(this.buildNodeDefinition(agentNode, agentId));
|
||||
}
|
||||
}
|
||||
|
||||
private markAgentSubgraphNodesDefined(
|
||||
agentNode: WorkflowNode,
|
||||
aiConnectedNodeNames: string[],
|
||||
nestedStickySubgraphs: Array<{ sticky: StickyBounds; nodeNames: string[] }>,
|
||||
): void {
|
||||
for (const name of aiConnectedNodeNames) {
|
||||
this.definedNodes.add(name);
|
||||
}
|
||||
for (const { nodeNames } of nestedStickySubgraphs) {
|
||||
for (const name of nodeNames) {
|
||||
this.definedNodes.add(name);
|
||||
}
|
||||
}
|
||||
this.definedNodes.add(agentNode.name);
|
||||
}
|
||||
|
||||
// Cross-subgraph connections
|
||||
|
||||
private buildConnectionsToSubgraphs(): void {
|
||||
for (const nodeName of this.definedNodes) {
|
||||
if (this.nodesInSubgraphs.has(nodeName)) continue;
|
||||
|
||||
const nodeConns = this.connections[nodeName];
|
||||
if (!nodeConns) continue;
|
||||
|
||||
for (const { nodeName: targetName, connType } of this.getConnectionTargets(nodeConns)) {
|
||||
if (this.nodesInSubgraphs.has(targetName)) {
|
||||
const sourceId = this.nodeIdMap.get(nodeName);
|
||||
const targetId = this.nodeIdMap.get(targetName);
|
||||
if (sourceId && targetId) {
|
||||
this.addConnection(sourceId, targetId, connType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildConnectionsFromSubgraphs(): void {
|
||||
const nodesToProcess: string[] = [];
|
||||
|
||||
for (const nodeName of this.nodesInSubgraphs) {
|
||||
const nodeConns = this.connections[nodeName];
|
||||
if (!nodeConns) continue;
|
||||
|
||||
const sourceId = this.nodeIdMap.get(nodeName);
|
||||
if (!sourceId) continue;
|
||||
|
||||
for (const { nodeName: targetName, connType } of this.getConnectionTargets(nodeConns)) {
|
||||
if (this.nodesInSubgraphs.has(targetName)) continue;
|
||||
|
||||
const wasNewMainConnection = this.defineTargetAndConnect(sourceId, targetName, connType);
|
||||
if (wasNewMainConnection) {
|
||||
nodesToProcess.push(targetName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.continueTraversalFromNodes(nodesToProcess);
|
||||
}
|
||||
|
||||
private continueTraversalFromNodes(nodesToProcess: string[]): void {
|
||||
const visited = new Set<string>();
|
||||
|
||||
const traverse = (nodeName: string) => {
|
||||
if (visited.has(nodeName) || this.nodesInSubgraphs.has(nodeName)) return;
|
||||
visited.add(nodeName);
|
||||
|
||||
const nodeConns = this.connections[nodeName];
|
||||
if (!nodeConns) return;
|
||||
|
||||
const sourceId = this.nodeIdMap.get(nodeName);
|
||||
if (!sourceId) return;
|
||||
|
||||
for (const { nodeName: targetName, connType } of this.getConnectionTargets(nodeConns)) {
|
||||
if (this.nodesInSubgraphs.has(targetName)) {
|
||||
const targetId = this.nodeIdMap.get(targetName);
|
||||
if (targetId) {
|
||||
this.addConnection(sourceId, targetId, connType);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this.defineTargetAndConnect(sourceId, targetName, connType);
|
||||
}
|
||||
|
||||
this.getMainConnectionTargets(nodeConns)
|
||||
.filter((t) => !this.nodesInSubgraphs.has(t))
|
||||
.forEach((t) => traverse(t));
|
||||
};
|
||||
|
||||
nodesToProcess.forEach((n) => traverse(n));
|
||||
}
|
||||
|
||||
private buildInterSubgraphConnections(): void {
|
||||
const nestedStickyIds = this.getNestedStickyIds();
|
||||
const outputConnections = new Set<string>();
|
||||
|
||||
for (const nodeName of this.nodesInSubgraphs) {
|
||||
const nodeConns = this.connections[nodeName];
|
||||
if (!nodeConns) continue;
|
||||
|
||||
for (const { nodeName: targetName, connType } of this.getConnectionTargets(nodeConns)) {
|
||||
if (!this.nodesInSubgraphs.has(targetName)) continue;
|
||||
|
||||
// Skip connections involving nested stickies (handled internally)
|
||||
if (this.isInNestedSticky(nodeName, nestedStickyIds)) continue;
|
||||
if (this.isInNestedSticky(targetName, nestedStickyIds)) continue;
|
||||
|
||||
const sourceSubgraphType = this.getSubgraphType(nodeName, nestedStickyIds);
|
||||
const targetSubgraphType = this.getSubgraphType(targetName, nestedStickyIds);
|
||||
|
||||
if (sourceSubgraphType === targetSubgraphType) continue;
|
||||
|
||||
const sourceId = this.nodeIdMap.get(nodeName);
|
||||
const targetId = this.nodeIdMap.get(targetName);
|
||||
if (!sourceId || !targetId) continue;
|
||||
|
||||
const connKey = `${sourceId}-${connType}-${targetId}`;
|
||||
if (outputConnections.has(connKey)) continue;
|
||||
outputConnections.add(connKey);
|
||||
|
||||
this.addConnection(sourceId, targetId, connType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isInNestedSticky(nodeName: string, nestedStickyIds: Set<string>): boolean {
|
||||
return this.stickyOverlaps.multiNodeOverlap.some(
|
||||
({ sticky, nodeNames }) =>
|
||||
nodeNames.includes(nodeName) && nestedStickyIds.has(sticky.node.id ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
private getSubgraphType(
|
||||
nodeName: string,
|
||||
nestedStickyIds: Set<string>,
|
||||
): 'sticky' | 'agent' | 'none' {
|
||||
const inStandaloneSticky = this.stickyOverlaps.multiNodeOverlap.some(
|
||||
({ sticky, nodeNames }) =>
|
||||
nodeNames.includes(nodeName) && !nestedStickyIds.has(sticky.node.id ?? ''),
|
||||
);
|
||||
if (inStandaloneSticky) return 'sticky';
|
||||
|
||||
const inAgentSubgraph = this.agentSubgraphs.some(
|
||||
({ agentNode, aiConnectedNodeNames }) =>
|
||||
agentNode.name === nodeName || aiConnectedNodeNames.includes(nodeName),
|
||||
);
|
||||
if (inAgentSubgraph) return 'agent';
|
||||
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
/**
|
||||
* Generates a Mermaid flowchart diagram from a workflow
|
||||
*/
|
||||
export function mermaidStringify(workflow: WorkflowMetadata, options?: MermaidOptions): string {
|
||||
const { workflow: wf } = workflow;
|
||||
const mergedOptions: Required<MermaidOptions> = {
|
||||
...DEFAULT_MERMAID_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
const builder = new MermaidBuilder(wf.nodes, wf.connections, mergedOptions);
|
||||
const result = builder.build();
|
||||
return result.lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple workflows and generate mermaid diagrams while collecting node configurations
|
||||
*/
|
||||
export function processWorkflowExamples(
|
||||
workflows: WorkflowMetadata[],
|
||||
options?: Omit<MermaidOptions, 'collectNodeConfigurations'>,
|
||||
): MermaidResult[] {
|
||||
const mergedOptions: Required<MermaidOptions> = {
|
||||
...DEFAULT_MERMAID_OPTIONS,
|
||||
...options,
|
||||
collectNodeConfigurations: true,
|
||||
};
|
||||
|
||||
const allConfigurations: NodeConfigurationsMap = {};
|
||||
|
||||
return workflows.map((workflow) => {
|
||||
const { workflow: wf } = workflow;
|
||||
const builder = new MermaidBuilder(wf.nodes, wf.connections, mergedOptions, allConfigurations);
|
||||
const result = builder.build();
|
||||
return {
|
||||
mermaid: result.lines.join('\n'),
|
||||
nodeConfigurations: result.nodeConfigurations,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { INodeParameters } from 'n8n-workflow';
|
||||
|
||||
import { MAX_NODE_EXAMPLE_CHARS } from '@/constants';
|
||||
import type { NodeConfigurationsMap, NodeConfigurationEntry, WorkflowMetadata } from '@/types';
|
||||
|
||||
/**
|
||||
* Node structure for configuration collection
|
||||
*/
|
||||
interface NodeForConfiguration {
|
||||
type: string;
|
||||
typeVersion: number;
|
||||
parameters: INodeParameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration from a single node if it meets size requirements
|
||||
* Returns null if the node has no parameters or exceeds size limits
|
||||
*/
|
||||
export function collectSingleNodeConfiguration(
|
||||
node: NodeForConfiguration,
|
||||
): NodeConfigurationEntry | null {
|
||||
const hasParams = Object.keys(node.parameters).length > 0;
|
||||
if (!hasParams) return null;
|
||||
|
||||
const parametersStr = JSON.stringify(node.parameters);
|
||||
if (parametersStr.length > MAX_NODE_EXAMPLE_CHARS) return null;
|
||||
|
||||
return {
|
||||
version: node.typeVersion,
|
||||
parameters: node.parameters,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a node configuration to a configurations map
|
||||
* Mutates the map in place for efficiency when processing multiple nodes
|
||||
*/
|
||||
export function addNodeConfigurationToMap(
|
||||
nodeType: string,
|
||||
config: NodeConfigurationEntry,
|
||||
configurationsMap: NodeConfigurationsMap,
|
||||
): void {
|
||||
if (!configurationsMap[nodeType]) {
|
||||
configurationsMap[nodeType] = [];
|
||||
}
|
||||
configurationsMap[nodeType].push(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect node configurations from multiple workflows
|
||||
* Does not generate mermaid diagrams - just extracts node configurations
|
||||
*/
|
||||
export function collectNodeConfigurationsFromWorkflows(
|
||||
workflows: WorkflowMetadata[],
|
||||
): NodeConfigurationsMap {
|
||||
const configurations: NodeConfigurationsMap = {};
|
||||
|
||||
for (const workflow of workflows) {
|
||||
for (const node of workflow.workflow.nodes) {
|
||||
// Skip sticky notes
|
||||
if (node.type === 'n8n-nodes-base.stickyNote') continue;
|
||||
|
||||
const config = collectSingleNodeConfiguration(node);
|
||||
if (config) {
|
||||
addNodeConfigurationToMap(node.type, config, configurations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return configurations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node configurations from cached templates on demand.
|
||||
* Filters templates containing the node type and extracts matching configurations.
|
||||
*
|
||||
* @param templates - The cached workflow templates to extract from
|
||||
* @param nodeType - The node type to filter by (e.g., 'n8n-nodes-base.telegram')
|
||||
* @param nodeVersion - Optional version to filter by
|
||||
* @returns Array of matching node configuration entries
|
||||
*/
|
||||
export function getNodeConfigurationsFromTemplates(
|
||||
templates: WorkflowMetadata[],
|
||||
nodeType: string,
|
||||
nodeVersion?: number,
|
||||
): NodeConfigurationEntry[] {
|
||||
const configurations: NodeConfigurationEntry[] = [];
|
||||
|
||||
for (const template of templates) {
|
||||
for (const node of template.workflow.nodes) {
|
||||
if (node.type !== nodeType) continue;
|
||||
if (nodeVersion !== undefined && node.typeVersion !== nodeVersion) continue;
|
||||
|
||||
const config = collectSingleNodeConfiguration(node);
|
||||
if (config) {
|
||||
configurations.push(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return configurations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format node configuration examples as markdown with token limit
|
||||
* Follows the same pattern as node-details.tool.ts for consistency
|
||||
*/
|
||||
export function formatNodeConfigurationExamples(
|
||||
nodeType: string,
|
||||
configurations: NodeConfigurationEntry[],
|
||||
nodeVersion?: number,
|
||||
maxExamples: number = 1,
|
||||
maxChars: number = MAX_NODE_EXAMPLE_CHARS,
|
||||
): string {
|
||||
// Filter by version if specified
|
||||
const filtered = nodeVersion
|
||||
? configurations.filter((c) => c.version === nodeVersion)
|
||||
: configurations;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return `## Node Configuration Examples: ${nodeType}\n\nNo examples found.`;
|
||||
}
|
||||
|
||||
// Limit to maxExamples and accumulate within token limit
|
||||
const limited = filtered.slice(0, maxExamples);
|
||||
const { parts } = limited.reduce<{ parts: string[]; chars: number }>(
|
||||
(acc, config) => {
|
||||
const exampleStr = JSON.stringify(config.parameters, null, 2);
|
||||
if (acc.chars + exampleStr.length <= maxChars) {
|
||||
acc.parts.push(
|
||||
`### Example (version ${config.version})`,
|
||||
'',
|
||||
'```json',
|
||||
exampleStr,
|
||||
'```',
|
||||
'',
|
||||
);
|
||||
acc.chars += exampleStr.length;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ parts: [], chars: 0 },
|
||||
);
|
||||
|
||||
return [`## Node Configuration Examples: ${nodeType}`, '', ...parts].join('\n');
|
||||
}
|
||||
-494
@@ -1,494 +0,0 @@
|
||||
import type { WorkflowMetadata } from '@/types';
|
||||
|
||||
import {
|
||||
mermaidStringify,
|
||||
processWorkflowExamples,
|
||||
stickyNotesStringify,
|
||||
} from '../markdown-workflow.utils';
|
||||
import { aiAssistantWorkflow } from './workflows/ai-assistant.workflow';
|
||||
|
||||
describe('markdown-workflow.utils', () => {
|
||||
describe('mermaidStringify', () => {
|
||||
it('should convert a workflow with AI agent and tools to mermaid diagram', () => {
|
||||
const result = mermaidStringify(aiAssistantWorkflow);
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
%% n8n-nodes-base.googleCalendarTool | {"operation":"getAll","calendar":{"__rl":true,"mode":"id","value":"=<insert email here>"},"options":{"timeMin":"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('After', \`\`, 'string') }}","timeMax":"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Before', \`\`, 'string') }}","fields":"=items(summary, start(dateTime))"}}
|
||||
n1["Google Calendar"]
|
||||
%% @n8n/n8n-nodes-langchain.memoryBufferWindow | {"sessionIdType":"customKey","sessionKey":"={{ $('Listen for incoming events').first().json.message.from.id }}"}
|
||||
n2["Window Buffer Memory"]
|
||||
%% n8n-nodes-base.gmailTool | {"operation":"getAll","limit":20,"filters":{"labelIds":["INBOX"],"readStatus":"unread","receivedAfter":"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Received_After', \`\`, 'string') }}","receivedBefore":"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Received_Before', \`\`, 'string') }}"}}
|
||||
n3["Get Email"]
|
||||
%% n8n-nodes-base.telegramTrigger | {"updates":["message"],"additionalFields":{}}
|
||||
n4["Listen for incoming events"]
|
||||
%% n8n-nodes-base.telegram | {"chatId":"={{ $('Listen for incoming events').first().json.message.from.id }}","text":"={{ $json.output }}","additionalFields":{"appendAttribution":false,"parse_mode":"Markdown"}}
|
||||
n5["Telegram"]
|
||||
%% n8n-nodes-base.if | {"conditions":{"options":{"version":2,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"a0bf9719-4272-46f6-ab3b-eda6f7b44fd8","operator":{"type":"string","operation":"empty","singleValue":true},"leftValue":"={{ $json.message.text }}","rightValue":""}]},"options":{}}
|
||||
n6["If"]
|
||||
%% n8n-nodes-base.set | {"fields":{"values":[{"name":"text","stringValue":"={{ $json?.message?.text || \\"\\" }}"}]},"options":{}}
|
||||
n7["Voice or Text"]
|
||||
%% n8n-nodes-base.telegram | {"resource":"file","fileId":"={{ $('Listen for incoming events').item.json.message.voice.file_id }}","additionalFields":{}}
|
||||
n8["Get Voice File"]
|
||||
%% @n8n/n8n-nodes-langchain.lmChatOpenRouter | {"options":{}}
|
||||
n9["OpenRouter"]
|
||||
%% n8n-nodes-base.googleTasksTool | {"task":"MTY1MTc5NzMxMzA5NDc5MTQ5NzQ6MDow","title":"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Title', \`\`, 'string') }}","additionalFields":{}}
|
||||
n10["Create a task in Google Tasks"]
|
||||
%% n8n-nodes-base.googleTasksTool | {"operation":"getAll","task":"MTY1MTc5NzMxMzA5NDc5MTQ5NzQ6MDow","additionalFields":{}}
|
||||
n11["Get many tasks in Google Tasks"]
|
||||
%% @n8n/n8n-nodes-langchain.openAi | {"resource":"audio","operation":"transcribe","options":{}}
|
||||
n12["Transcribe a recording"]
|
||||
%% n8n-nodes-base.gmailTool | {"sendTo":"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', \`\`, 'string') }}","subject":"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Subject', \`\`, 'string') }}","message":"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Message', \`Please format this nicely in html\`, 'string') }}","options":{"appendAttribution":false}}
|
||||
n13["Send Email"]
|
||||
%% @n8n/n8n-nodes-langchain.agent | {"promptType":"define","text":"={{ $json.text }}","options":{"systemMessage":"=You are a helpful personal assistant called Jackie. \\n\\nToday's date is {{ $today.format('yyyy-MM-dd') }}.\\n\\nGuidelines:\\n- When summarizing emails, include Sender, Message date, subject, and brief summary of email.\\n- if the user did not specify a date in the request assume they are asking for today\\n- When answering questions about calendar events, filter out events that don't apply to the question. For example, the question is about events for today, only reply with events for today. Don't mention future events if it's more than 1 week away\\n- When creating calendar entry, the attendee email is optional"}}
|
||||
n14["Jackie, AI Assistant 👩🏻🏫"]
|
||||
n1 -.ai_tool.-> n14
|
||||
n2 -.ai_memory.-> n14
|
||||
n3 -.ai_tool.-> n14
|
||||
n4 --> n7
|
||||
n7 --> n6
|
||||
n6 --> n8
|
||||
n6 --> n14
|
||||
n8 --> n12
|
||||
n12 --> n14
|
||||
n14 --> n5
|
||||
n9 -.ai_languageModel.-> n14
|
||||
n10 -.ai_tool.-> n14
|
||||
n11 -.ai_tool.-> n14
|
||||
n13 -.ai_tool.-> n14
|
||||
\`\`\``;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert a workflow with AI agent and tools to mermaid diagram without node parameters', () => {
|
||||
const result = mermaidStringify(aiAssistantWorkflow, { includeNodeParameters: false });
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
%% n8n-nodes-base.googleCalendarTool
|
||||
n1["Google Calendar"]
|
||||
%% @n8n/n8n-nodes-langchain.memoryBufferWindow
|
||||
n2["Window Buffer Memory"]
|
||||
%% n8n-nodes-base.gmailTool
|
||||
n3["Get Email"]
|
||||
%% n8n-nodes-base.telegramTrigger
|
||||
n4["Listen for incoming events"]
|
||||
%% n8n-nodes-base.telegram
|
||||
n5["Telegram"]
|
||||
%% n8n-nodes-base.if
|
||||
n6["If"]
|
||||
%% n8n-nodes-base.set
|
||||
n7["Voice or Text"]
|
||||
%% n8n-nodes-base.telegram
|
||||
n8["Get Voice File"]
|
||||
%% @n8n/n8n-nodes-langchain.lmChatOpenRouter
|
||||
n9["OpenRouter"]
|
||||
%% n8n-nodes-base.googleTasksTool
|
||||
n10["Create a task in Google Tasks"]
|
||||
%% n8n-nodes-base.googleTasksTool
|
||||
n11["Get many tasks in Google Tasks"]
|
||||
%% @n8n/n8n-nodes-langchain.openAi
|
||||
n12["Transcribe a recording"]
|
||||
%% n8n-nodes-base.gmailTool
|
||||
n13["Send Email"]
|
||||
%% @n8n/n8n-nodes-langchain.agent
|
||||
n14["Jackie, AI Assistant 👩🏻🏫"]
|
||||
n1 -.ai_tool.-> n14
|
||||
n2 -.ai_memory.-> n14
|
||||
n3 -.ai_tool.-> n14
|
||||
n4 --> n7
|
||||
n7 --> n6
|
||||
n6 --> n8
|
||||
n6 --> n14
|
||||
n8 --> n12
|
||||
n12 --> n14
|
||||
n14 --> n5
|
||||
n9 -.ai_languageModel.-> n14
|
||||
n10 -.ai_tool.-> n14
|
||||
n11 -.ai_tool.-> n14
|
||||
n13 -.ai_tool.-> n14
|
||||
\`\`\``;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle workflow with single node', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
name: 'Simple Workflow',
|
||||
workflow: {
|
||||
name: 'Simple Workflow',
|
||||
nodes: [
|
||||
{
|
||||
parameters: { updates: ['message'] },
|
||||
id: 'node1',
|
||||
name: 'Trigger',
|
||||
type: 'n8n-nodes-base.telegramTrigger',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow);
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
%% n8n-nodes-base.telegramTrigger | {"updates":["message"]}
|
||||
n1["Trigger"]
|
||||
\`\`\``;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle workflow with branching connections', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
name: 'Branching Workflow',
|
||||
workflow: {
|
||||
name: 'Branching Workflow',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
id: 'if1',
|
||||
name: 'If',
|
||||
type: 'n8n-nodes-base.if',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {},
|
||||
id: 'node1',
|
||||
name: 'True Branch',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [100, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {},
|
||||
id: 'node2',
|
||||
name: 'False Branch',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [100, 100],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {},
|
||||
id: 'node3',
|
||||
name: 'Send Success Email',
|
||||
type: 'n8n-nodes-base.emailSend',
|
||||
position: [200, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {},
|
||||
id: 'node4',
|
||||
name: 'Send Failure Email',
|
||||
type: 'n8n-nodes-base.emailSend',
|
||||
position: [200, 100],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
If: {
|
||||
main: [
|
||||
[{ node: 'True Branch', type: 'main', index: 0 }],
|
||||
[{ node: 'False Branch', type: 'main', index: 0 }],
|
||||
],
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'True Branch': {
|
||||
main: [[{ node: 'Send Success Email', type: 'main', index: 0 }]],
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'False Branch': {
|
||||
main: [[{ node: 'Send Failure Email', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow);
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
%% n8n-nodes-base.if
|
||||
n1["If"]
|
||||
%% n8n-nodes-base.set
|
||||
n2["True Branch"]
|
||||
%% n8n-nodes-base.set
|
||||
n3["False Branch"]
|
||||
%% n8n-nodes-base.emailSend
|
||||
n4["Send Success Email"]
|
||||
%% n8n-nodes-base.emailSend
|
||||
n5["Send Failure Email"]
|
||||
n1 --> n2
|
||||
n1 --> n3
|
||||
n2 --> n4
|
||||
n3 --> n5
|
||||
\`\`\``;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle nodes without parameters', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
name: 'Empty Params',
|
||||
workflow: {
|
||||
name: 'Empty Params',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
id: 'node1',
|
||||
name: 'Empty Node',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow);
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
%% n8n-nodes-base.noOp
|
||||
n1["Empty Node"]
|
||||
\`\`\``;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should exclude sticky notes from mermaid diagram', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
name: 'With Sticky',
|
||||
workflow: {
|
||||
name: 'With Sticky',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: { content: 'This is a note' },
|
||||
id: 'sticky1',
|
||||
name: 'Sticky Note',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [100, 100],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow);
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
%% n8n-nodes-base.manualTrigger
|
||||
n1["Start"]
|
||||
\`\`\``;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stickyNotesStringify', () => {
|
||||
it('should convert workflow sticky notes to bullet list', () => {
|
||||
const result = stickyNotesStringify(aiAssistantWorkflow);
|
||||
|
||||
// Each sticky note should start with "- "
|
||||
const lines = result.split('\n');
|
||||
const bulletLines = lines.filter((line: string) => line.startsWith('- '));
|
||||
expect(bulletLines.length).toBeGreaterThan(0);
|
||||
|
||||
// Should contain key content from sticky notes
|
||||
expect(result).toContain('Process Telegram Request');
|
||||
expect(result).toContain('OpenRouter');
|
||||
expect(result).toContain('Try It Out');
|
||||
expect(result).toContain('Video Tutorial');
|
||||
expect(result).toContain('youtube');
|
||||
});
|
||||
|
||||
it('should return empty string for workflow without sticky notes', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
name: 'No Sticky Notes',
|
||||
workflow: {
|
||||
name: 'No Sticky Notes',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = stickyNotesStringify(workflow);
|
||||
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processWorkflowExamples', () => {
|
||||
it('should generate mermaid diagrams and collect node configurations in one pass', () => {
|
||||
const workflow1: WorkflowMetadata = {
|
||||
name: 'Workflow 1',
|
||||
workflow: {
|
||||
name: 'Workflow 1',
|
||||
nodes: [
|
||||
{
|
||||
parameters: { updates: ['message'] },
|
||||
id: 'node1',
|
||||
name: 'Telegram Trigger',
|
||||
type: 'n8n-nodes-base.telegramTrigger',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: { chatId: '123', text: 'Hello' },
|
||||
id: 'node2',
|
||||
name: 'Send Message',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
position: [200, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Telegram Trigger': {
|
||||
main: [[{ node: 'Send Message', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const workflow2: WorkflowMetadata = {
|
||||
name: 'Workflow 2',
|
||||
workflow: {
|
||||
name: 'Workflow 2',
|
||||
nodes: [
|
||||
{
|
||||
parameters: { chatId: '456', text: 'World' },
|
||||
id: 'node3',
|
||||
name: 'Another Telegram',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: { operation: 'getAll' },
|
||||
id: 'node4',
|
||||
name: 'Gmail',
|
||||
type: 'n8n-nodes-base.gmail',
|
||||
position: [200, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const results = processWorkflowExamples([workflow1, workflow2], {
|
||||
includeNodeParameters: false,
|
||||
});
|
||||
|
||||
// Should return results for each workflow
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
// Each result should have mermaid string
|
||||
expect(results[0].mermaid).toContain('```mermaid');
|
||||
expect(results[0].mermaid).toContain('n8n-nodes-base.telegramTrigger');
|
||||
expect(results[1].mermaid).toContain('n8n-nodes-base.gmail');
|
||||
|
||||
// Node configurations should be accumulated across all workflows
|
||||
const nodeConfigs = results[1].nodeConfigurations;
|
||||
|
||||
// Should have telegram trigger config from workflow1 with version info
|
||||
expect(nodeConfigs['n8n-nodes-base.telegramTrigger']).toHaveLength(1);
|
||||
expect(nodeConfigs['n8n-nodes-base.telegramTrigger'][0]).toEqual({
|
||||
version: 1,
|
||||
parameters: { updates: ['message'] },
|
||||
});
|
||||
|
||||
// Should have both telegram configs (from workflow1 and workflow2) with version info
|
||||
expect(nodeConfigs['n8n-nodes-base.telegram']).toHaveLength(2);
|
||||
expect(nodeConfigs['n8n-nodes-base.telegram']).toContainEqual({
|
||||
version: 1,
|
||||
parameters: { chatId: '123', text: 'Hello' },
|
||||
});
|
||||
expect(nodeConfigs['n8n-nodes-base.telegram']).toContainEqual({
|
||||
version: 1,
|
||||
parameters: { chatId: '456', text: 'World' },
|
||||
});
|
||||
|
||||
// Should have gmail config from workflow2 with version info
|
||||
expect(nodeConfigs['n8n-nodes-base.gmail']).toHaveLength(1);
|
||||
expect(nodeConfigs['n8n-nodes-base.gmail'][0]).toEqual({
|
||||
version: 1,
|
||||
parameters: { operation: 'getAll' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty configurations for empty workflow list', () => {
|
||||
const results = processWorkflowExamples([]);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip nodes with empty parameters', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
name: 'Empty Params',
|
||||
workflow: {
|
||||
name: 'Empty Params',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
id: 'node1',
|
||||
name: 'Empty Node',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: { value: 'test' },
|
||||
id: 'node2',
|
||||
name: 'Set Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const results = processWorkflowExamples([workflow]);
|
||||
const nodeConfigs = results[0].nodeConfigurations;
|
||||
|
||||
// Should not have noOp since it has empty parameters
|
||||
expect(nodeConfigs['n8n-nodes-base.noOp']).toBeUndefined();
|
||||
|
||||
// Should have set node config with version info
|
||||
expect(nodeConfigs['n8n-nodes-base.set']).toHaveLength(1);
|
||||
expect(nodeConfigs['n8n-nodes-base.set'][0]).toEqual({
|
||||
version: 1,
|
||||
parameters: { value: 'test' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+1
@@ -3,6 +3,7 @@ import type { WorkflowMetadata } from '@/types';
|
||||
// template for test: https://n8n.io/workflows/8237-personal-life-manager-with-telegram-google-services-and-voice-enabled-ai/
|
||||
|
||||
export const aiAssistantWorkflow: WorkflowMetadata = {
|
||||
templateId: 8237,
|
||||
name: 'Personal Life Manager with Telegram, Google Services & Voice-Enabled AI',
|
||||
description:
|
||||
'This project teaches you to create a personal AI assistant named Jackie that operates through Telegram. Jackie can summarize unread emails, check calendar events, manage Google Tasks, and handle both voice and text interactions. The assistant provides a comprehensive digital life management solution accessible via Telegram messaging.',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
|
||||
import type {
|
||||
TemplateSearchQuery,
|
||||
TemplateSearchResponse,
|
||||
Category,
|
||||
TemplateFetchResponse,
|
||||
WorkflowMetadata,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
@@ -49,6 +52,7 @@ function buildSearchQueryString(query: TemplateSearchQuery): string {
|
||||
// Optional user-provided values
|
||||
if (query.search) params.append('search', query.search);
|
||||
if (query.category) params.append('category', query.category);
|
||||
if (query.nodes) params.append('nodes', query.nodes);
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
@@ -60,6 +64,7 @@ export async function fetchTemplateList(query: {
|
||||
search?: string;
|
||||
category?: Category;
|
||||
rows?: number;
|
||||
nodes?: string;
|
||||
}): Promise<TemplateSearchResponse> {
|
||||
const queryString = buildSearchQueryString(query);
|
||||
const url = `${N8N_API_BASE_URL}/templates/search${queryString ? `?${queryString}` : ''}`;
|
||||
@@ -107,3 +112,75 @@ export async function fetchTemplateByID(id: number): Promise<TemplateFetchRespon
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of fetching workflows from templates
|
||||
*/
|
||||
export interface FetchWorkflowsResult {
|
||||
workflows: WorkflowMetadata[];
|
||||
totalFound: number;
|
||||
templateIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workflows from templates API and return full workflow data
|
||||
* Shared utility used by both get-workflow-examples and get-node-examples tools
|
||||
*/
|
||||
export async function fetchWorkflowsFromTemplates(
|
||||
query: {
|
||||
search?: string;
|
||||
category?: Category;
|
||||
rows?: number;
|
||||
nodes?: string;
|
||||
},
|
||||
options?: {
|
||||
/** Maximum number of templates to fetch full data for (default: all) */
|
||||
maxTemplates?: number;
|
||||
logger?: Logger;
|
||||
},
|
||||
): Promise<FetchWorkflowsResult> {
|
||||
const { maxTemplates, logger } = options ?? {};
|
||||
|
||||
logger?.debug('Fetching workflows from templates', { query });
|
||||
|
||||
// First, fetch the list of workflow templates (metadata)
|
||||
const response = await fetchTemplateList(query);
|
||||
|
||||
// Determine which templates to fetch full data for
|
||||
const templatesToFetch = maxTemplates
|
||||
? response.workflows.slice(0, maxTemplates)
|
||||
: response.workflows;
|
||||
|
||||
// Fetch complete workflow data for each template
|
||||
const workflowResults = await Promise.all(
|
||||
templatesToFetch.map(async (template) => {
|
||||
try {
|
||||
const fullWorkflow = await fetchTemplateByID(template.id);
|
||||
return {
|
||||
metadata: {
|
||||
templateId: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
workflow: fullWorkflow.workflow,
|
||||
} satisfies WorkflowMetadata,
|
||||
templateId: template.id,
|
||||
};
|
||||
} catch (error) {
|
||||
// Individual template fetch failures are non-fatal
|
||||
logger?.warn(`Failed to fetch full workflow for template ${template.id}`, { error });
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Filter out failed fetches
|
||||
const validResults = workflowResults.filter(
|
||||
(result): result is NonNullable<typeof result> => result !== null,
|
||||
);
|
||||
|
||||
return {
|
||||
workflows: validResults.map((r) => r.metadata),
|
||||
totalFound: response.totalWorkflows,
|
||||
templateIds: validResults.map((r) => r.templateId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { NodeConfigurationsMap } from './tools';
|
||||
|
||||
export interface DiscoveryContext {
|
||||
nodesFound: Array<{
|
||||
nodeName: string;
|
||||
@@ -11,5 +9,4 @@ export interface DiscoveryContext {
|
||||
}>;
|
||||
}>;
|
||||
bestPractices?: string;
|
||||
nodeConfigurations?: NodeConfigurationsMap;
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ export interface CategorizePromptOutput {
|
||||
* Description of a workflow example we have found
|
||||
*/
|
||||
export interface WorkflowMetadata {
|
||||
templateId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
workflow: SimpleWorkflow;
|
||||
@@ -188,5 +189,13 @@ export interface GetWorkflowExamplesOutput {
|
||||
workflow: string;
|
||||
}>;
|
||||
totalResults: number;
|
||||
nodeConfigurations: NodeConfigurationsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output type for get node configuration examples tool
|
||||
*/
|
||||
export interface GetNodeConfigurationExamplesOutput {
|
||||
nodeType: string;
|
||||
totalFound: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ export interface TemplateSearchQuery {
|
||||
combineWith?: 'or' | 'and';
|
||||
// category can be used to search by a pre-defined list
|
||||
category?: Category;
|
||||
// there are apps/nodes search properties as well - but have a specific format which is
|
||||
// a specific node is used in the template, should be in node format like n8n-nodes-base.editImage
|
||||
nodes?: string;
|
||||
// there are apps search properties as well - but have a specific format which is
|
||||
// hard to feed to the agent for use in search (free search will work better)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NodeConfigurationsMap } from '../types/tools';
|
||||
import type { WorkflowMetadata } from '../types';
|
||||
|
||||
/**
|
||||
* Reducer for appending arrays with null/empty check.
|
||||
@@ -9,33 +9,26 @@ export function appendArrayReducer<T>(current: T[], update: T[] | undefined | nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge node configurations by type, appending new configs to existing ones.
|
||||
* Used as a standalone utility function for merging node configurations outside of reducers.
|
||||
* Reducer for caching workflow templates, deduplicating by template ID.
|
||||
* Merges new templates with existing ones, avoiding duplicates.
|
||||
*/
|
||||
export function mergeNodeConfigurations(
|
||||
target: NodeConfigurationsMap,
|
||||
source: NodeConfigurationsMap,
|
||||
): void {
|
||||
for (const [nodeType, configs] of Object.entries(source)) {
|
||||
if (!target[nodeType]) {
|
||||
target[nodeType] = [];
|
||||
}
|
||||
target[nodeType].push(...configs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reducer for merging node configurations by type.
|
||||
* Appends new configurations to existing ones for each node type.
|
||||
*/
|
||||
export function nodeConfigurationsReducer(
|
||||
current: NodeConfigurationsMap,
|
||||
update: NodeConfigurationsMap | undefined | null,
|
||||
): NodeConfigurationsMap {
|
||||
if (!update || Object.keys(update).length === 0) {
|
||||
export function cachedTemplatesReducer(
|
||||
current: WorkflowMetadata[],
|
||||
update: WorkflowMetadata[] | undefined | null,
|
||||
): WorkflowMetadata[] {
|
||||
if (!update || update.length === 0) {
|
||||
return current;
|
||||
}
|
||||
const merged = { ...current };
|
||||
mergeNodeConfigurations(merged, update);
|
||||
return merged;
|
||||
|
||||
// Build a map of existing templates by ID for fast lookup
|
||||
const existingById = new Map(current.map((wf) => [wf.templateId, wf]));
|
||||
|
||||
// Add new templates that don't already exist
|
||||
for (const workflow of update) {
|
||||
if (!existingById.has(workflow.templateId)) {
|
||||
existingById.set(workflow.templateId, workflow);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(existingById.values());
|
||||
}
|
||||
|
||||
@@ -3,16 +3,15 @@ import { isAIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages
|
||||
import type { StructuredTool } from '@langchain/core/tools';
|
||||
import { isCommand, END } from '@langchain/langgraph';
|
||||
|
||||
import { mergeNodeConfigurations } from './state-reducers';
|
||||
import { isBaseMessage } from '../types/langchain';
|
||||
import type { NodeConfigurationsMap } from '../types/tools';
|
||||
import type { WorkflowMetadata } from '../types/tools';
|
||||
import type { WorkflowOperation } from '../types/workflow';
|
||||
|
||||
interface CommandUpdate {
|
||||
messages?: BaseMessage[];
|
||||
workflowOperations?: WorkflowOperation[];
|
||||
templateIds?: number[];
|
||||
nodeConfigurations?: NodeConfigurationsMap;
|
||||
cachedTemplates?: WorkflowMetadata[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,11 +38,11 @@ function isCommandUpdate(value: unknown): value is CommandUpdate {
|
||||
if ('templateIds' in obj && obj.templateIds !== undefined && !Array.isArray(obj.templateIds)) {
|
||||
return false;
|
||||
}
|
||||
// nodeConfigurations is optional, but if present must be an object
|
||||
// cachedTemplates is optional, but if present must be an array
|
||||
if (
|
||||
'nodeConfigurations' in obj &&
|
||||
obj.nodeConfigurations !== undefined &&
|
||||
(typeof obj.nodeConfigurations !== 'object' || obj.nodeConfigurations === null)
|
||||
'cachedTemplates' in obj &&
|
||||
obj.cachedTemplates !== undefined &&
|
||||
!Array.isArray(obj.cachedTemplates)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -67,7 +66,7 @@ export async function executeSubgraphTools(
|
||||
messages?: BaseMessage[];
|
||||
workflowOperations?: WorkflowOperation[] | null;
|
||||
templateIds?: number[];
|
||||
nodeConfigurations?: NodeConfigurationsMap;
|
||||
cachedTemplates?: WorkflowMetadata[];
|
||||
}> {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
@@ -106,11 +105,11 @@ export async function executeSubgraphTools(
|
||||
}),
|
||||
);
|
||||
|
||||
// Unwrap Command objects and collect messages/operations/templateIds/nodeConfigurations
|
||||
// Unwrap Command objects and collect messages/operations/templateIds/cachedTemplates
|
||||
const messages: BaseMessage[] = [];
|
||||
const operations: WorkflowOperation[] = [];
|
||||
const templateIds: number[] = [];
|
||||
const nodeConfigurations: NodeConfigurationsMap = {};
|
||||
const cachedTemplates: WorkflowMetadata[] = [];
|
||||
|
||||
for (const result of toolResults) {
|
||||
if (isCommand(result)) {
|
||||
@@ -125,8 +124,8 @@ export async function executeSubgraphTools(
|
||||
if (result.update.templateIds) {
|
||||
templateIds.push(...result.update.templateIds);
|
||||
}
|
||||
if (result.update.nodeConfigurations) {
|
||||
mergeNodeConfigurations(nodeConfigurations, result.update.nodeConfigurations);
|
||||
if (result.update.cachedTemplates) {
|
||||
cachedTemplates.push(...result.update.cachedTemplates);
|
||||
}
|
||||
}
|
||||
} else if (isBaseMessage(result)) {
|
||||
@@ -139,7 +138,7 @@ export async function executeSubgraphTools(
|
||||
messages?: BaseMessage[];
|
||||
workflowOperations?: WorkflowOperation[] | null;
|
||||
templateIds?: number[];
|
||||
nodeConfigurations?: NodeConfigurationsMap;
|
||||
cachedTemplates?: WorkflowMetadata[];
|
||||
} = {};
|
||||
|
||||
if (messages.length > 0) {
|
||||
@@ -154,8 +153,8 @@ export async function executeSubgraphTools(
|
||||
stateUpdate.templateIds = templateIds;
|
||||
}
|
||||
|
||||
if (Object.keys(nodeConfigurations).length > 0) {
|
||||
stateUpdate.nodeConfigurations = nodeConfigurations;
|
||||
if (cachedTemplates.length > 0) {
|
||||
stateUpdate.cachedTemplates = cachedTemplates;
|
||||
}
|
||||
|
||||
return stateUpdate;
|
||||
|
||||
@@ -596,8 +596,8 @@ describe('operations-processor', () => {
|
||||
validationHistory: [],
|
||||
techniqueCategories: [],
|
||||
previousSummary: 'EMPTY',
|
||||
nodeConfigurations: {},
|
||||
templateIds: [],
|
||||
cachedTemplates: [],
|
||||
});
|
||||
|
||||
it('should process operations and clear them', () => {
|
||||
|
||||
@@ -52,8 +52,8 @@ describe('tool-executor', () => {
|
||||
validationHistory: [],
|
||||
techniqueCategories: [],
|
||||
previousSummary: 'EMPTY',
|
||||
nodeConfigurations: {},
|
||||
templateIds: [],
|
||||
cachedTemplates: [],
|
||||
});
|
||||
|
||||
// Helper to create mock tool
|
||||
@@ -819,26 +819,31 @@ describe('tool-executor', () => {
|
||||
expect(result.techniqueCategories).toEqual([...categories1, ...categories2]);
|
||||
});
|
||||
|
||||
it('should collect nodeConfigurations from tool state updates', async () => {
|
||||
const configs1 = {
|
||||
'n8n-nodes-base.telegram': [{ version: 1, parameters: { chatId: '123', text: 'Hello' } }],
|
||||
};
|
||||
const configs2 = {
|
||||
'n8n-nodes-base.telegram': [{ version: 1, parameters: { chatId: '456', text: 'World' } }],
|
||||
'n8n-nodes-base.gmail': [{ version: 2, parameters: { operation: 'send' } }],
|
||||
};
|
||||
it('should collect cachedTemplates from tool state updates', async () => {
|
||||
const templates1 = [
|
||||
{
|
||||
name: 'Template 1',
|
||||
workflow: { nodes: [], connections: {}, name: 'Template 1' },
|
||||
},
|
||||
];
|
||||
const templates2 = [
|
||||
{
|
||||
name: 'Template 2',
|
||||
workflow: { nodes: [], connections: {}, name: 'Template 2' },
|
||||
},
|
||||
];
|
||||
|
||||
const command1 = new MockCommand({
|
||||
update: {
|
||||
messages: [new ToolMessage({ content: 'Examples', tool_call_id: 'call-1' })],
|
||||
nodeConfigurations: configs1,
|
||||
cachedTemplates: templates1,
|
||||
},
|
||||
});
|
||||
|
||||
const command2 = new MockCommand({
|
||||
update: {
|
||||
messages: [new ToolMessage({ content: 'More Examples', tool_call_id: 'call-2' })],
|
||||
nodeConfigurations: configs2,
|
||||
cachedTemplates: templates2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -870,10 +875,9 @@ describe('tool-executor', () => {
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.nodeConfigurations).toBeDefined();
|
||||
// Should have 2 telegram configs merged and 1 gmail config
|
||||
expect(result.nodeConfigurations?.['n8n-nodes-base.telegram']).toHaveLength(2);
|
||||
expect(result.nodeConfigurations?.['n8n-nodes-base.gmail']).toHaveLength(1);
|
||||
expect(result.cachedTemplates).toBeDefined();
|
||||
// Should have 2 templates collected
|
||||
expect(result.cachedTemplates).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isCommand } from '@langchain/langgraph';
|
||||
|
||||
import { ToolExecutionError, WorkflowStateError } from '../errors';
|
||||
import type { ToolExecutorOptions } from '../types/config';
|
||||
import type { NodeConfigurationsMap } from '../types/tools';
|
||||
import type { WorkflowMetadata } from '../types/tools';
|
||||
import type { WorkflowOperation } from '../types/workflow';
|
||||
import type { WorkflowState } from '../workflow-state';
|
||||
|
||||
@@ -39,27 +39,6 @@ function collectArrayFromUpdates<T>(updates: StateUpdate[], key: keyof StateUpda
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge node configurations from multiple state updates
|
||||
* Configurations are grouped by node type
|
||||
*/
|
||||
function mergeNodeConfigurations(updates: StateUpdate[]): NodeConfigurationsMap {
|
||||
const merged: NodeConfigurationsMap = {};
|
||||
|
||||
for (const update of updates) {
|
||||
if (update.nodeConfigurations && typeof update.nodeConfigurations === 'object') {
|
||||
for (const [nodeType, configs] of Object.entries(update.nodeConfigurations)) {
|
||||
if (!merged[nodeType]) {
|
||||
merged[nodeType] = [];
|
||||
}
|
||||
merged[nodeType].push(...configs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error ToolMessage for failed tool invocations
|
||||
*/
|
||||
@@ -182,12 +161,15 @@ export async function executeToolsInParallel(
|
||||
(typeof WorkflowState.State.validationHistory)[number]
|
||||
>(stateUpdates, 'validationHistory');
|
||||
|
||||
// Merge node configurations from all updates
|
||||
const allNodeConfigurations = mergeNodeConfigurations(stateUpdates);
|
||||
|
||||
// Collect template IDs from all updates
|
||||
const allTemplateIds = collectArrayFromUpdates<number>(stateUpdates, 'templateIds');
|
||||
|
||||
// Collect cached templates from all updates
|
||||
const allCachedTemplates = collectArrayFromUpdates<WorkflowMetadata>(
|
||||
stateUpdates,
|
||||
'cachedTemplates',
|
||||
);
|
||||
|
||||
// Return the combined update
|
||||
const finalUpdate: Partial<typeof WorkflowState.State> = {
|
||||
messages: allMessages,
|
||||
@@ -205,13 +187,13 @@ export async function executeToolsInParallel(
|
||||
finalUpdate.validationHistory = allValidationHistory;
|
||||
}
|
||||
|
||||
if (Object.keys(allNodeConfigurations).length > 0) {
|
||||
finalUpdate.nodeConfigurations = allNodeConfigurations;
|
||||
}
|
||||
|
||||
if (allTemplateIds.length > 0) {
|
||||
finalUpdate.templateIds = allTemplateIds;
|
||||
}
|
||||
|
||||
if (allCachedTemplates.length > 0) {
|
||||
finalUpdate.cachedTemplates = allCachedTemplates;
|
||||
}
|
||||
|
||||
return finalUpdate;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { INodeTypeDescription, INodeParameters } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types';
|
||||
import { createNodeTypeMaps, getNodeTypeForNode } from '@/validation/utils/node-type-map';
|
||||
|
||||
import type { ProgrammaticViolation } from '../types';
|
||||
import { isTool } from '../utils/is-tool';
|
||||
@@ -53,8 +54,10 @@ export function validateFromAi(
|
||||
return violations;
|
||||
}
|
||||
|
||||
const { nodeTypeMap, nodeTypesByName } = createNodeTypeMaps(nodeTypes);
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
const nodeType = nodeTypes.find((type) => type.name === node.type);
|
||||
const nodeType = getNodeTypeForNode(node, nodeTypeMap, nodeTypesByName);
|
||||
if (!nodeType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types';
|
||||
import { createNodeTypeMaps, getNodeTypeForNode } from '@/validation/utils/node-type-map';
|
||||
|
||||
import type { ProgrammaticViolation } from '../types';
|
||||
|
||||
@@ -21,6 +22,9 @@ export function validateNodes(
|
||||
return violations;
|
||||
}
|
||||
|
||||
const { nodeTypeMap, nodeTypesByName } = createNodeTypeMaps(nodeTypes);
|
||||
|
||||
// Group nodes by type for counting
|
||||
const nodeCountByType = new Map<string, number>();
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
@@ -28,13 +32,24 @@ export function validateNodes(
|
||||
nodeCountByType.set(node.type, currentCount + 1);
|
||||
}
|
||||
|
||||
for (const [nodeTypeName, count] of nodeCountByType) {
|
||||
const nodeType = nodeTypes.find((type) => type.name === nodeTypeName);
|
||||
// For maxNodes validation, we check each unique node type
|
||||
// We use the first occurrence's version to look up the node type
|
||||
const checkedTypes = new Set<string>();
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
if (checkedTypes.has(node.type)) {
|
||||
continue;
|
||||
}
|
||||
checkedTypes.add(node.type);
|
||||
|
||||
const nodeType = getNodeTypeForNode(node, nodeTypeMap, nodeTypesByName);
|
||||
|
||||
if (!nodeType?.maxNodes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const count = nodeCountByType.get(node.type) ?? 0;
|
||||
|
||||
if (count > nodeType.maxNodes) {
|
||||
violations.push({
|
||||
name: 'workflow-exceeds-max-nodes-limit',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types';
|
||||
import { createNodeTypeMaps, getNodeTypeForNode } from '@/validation/utils/node-type-map';
|
||||
|
||||
import type { SingleEvaluatorResult } from '../types';
|
||||
import { isTool } from '../utils/is-tool';
|
||||
@@ -24,8 +25,10 @@ export function validateTools(
|
||||
return violations;
|
||||
}
|
||||
|
||||
const { nodeTypeMap, nodeTypesByName } = createNodeTypeMaps(nodeTypes);
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
const nodeType = nodeTypes.find((type) => type.name === node.type);
|
||||
const nodeType = getNodeTypeForNode(node, nodeTypeMap, nodeTypesByName);
|
||||
if (!nodeType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types';
|
||||
import { createNodeTypeMaps, getNodeTypeForNode } from '@/validation/utils/node-type-map';
|
||||
|
||||
import type { ProgrammaticViolation, SingleEvaluatorResult } from '../types';
|
||||
|
||||
@@ -22,8 +23,10 @@ export function validateTrigger(
|
||||
return violations;
|
||||
}
|
||||
|
||||
const { nodeTypeMap, nodeTypesByName } = createNodeTypeMaps(nodeTypes);
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
const nodeType = nodeTypes.find((type) => type.name === node.type);
|
||||
const nodeType = getNodeTypeForNode(node, nodeTypeMap, nodeTypesByName);
|
||||
|
||||
if (!nodeType) {
|
||||
continue;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { Annotation, messagesStateReducer } from '@langchain/langgraph';
|
||||
|
||||
import type { NodeConfigurationsMap, SimpleWorkflow, WorkflowOperation } from './types';
|
||||
import { appendArrayReducer, nodeConfigurationsReducer } from './utils/state-reducers';
|
||||
import type { SimpleWorkflow, WorkflowMetadata, WorkflowOperation } from './types';
|
||||
import { appendArrayReducer, cachedTemplatesReducer } from './utils/state-reducers';
|
||||
import type { ProgrammaticEvaluationResult, TelemetryValidationStatus } from './validation/types';
|
||||
import type { ChatPayload } from './workflow-builder-agent';
|
||||
|
||||
@@ -103,16 +103,16 @@ export const WorkflowState = Annotation.Root({
|
||||
default: () => 'EMPTY',
|
||||
}),
|
||||
|
||||
// Node configurations collected from workflow examples
|
||||
// Used to provide context when updating node parameters
|
||||
nodeConfigurations: Annotation<NodeConfigurationsMap>({
|
||||
reducer: nodeConfigurationsReducer,
|
||||
default: () => ({}),
|
||||
}),
|
||||
|
||||
// Template IDs fetched from workflow examples for telemetry
|
||||
templateIds: Annotation<number[]>({
|
||||
reducer: appendArrayReducer,
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Cached workflow templates from template API
|
||||
// Shared across tools to reduce API calls
|
||||
cachedTemplates: Annotation<WorkflowMetadata[]>({
|
||||
reducer: cachedTemplatesReducer,
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user