mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-27 14:57:21 +02:00
feat(ai-builder): Using templates to improve generation (#22521)
This commit is contained in:
parent
3b5d4d6a10
commit
7186dcfe7e
|
|
@ -258,6 +258,10 @@ export class AiWorkflowBuilderService {
|
|||
tools_called: toolsCalled,
|
||||
techniques_categories: state.values.techniqueCategories,
|
||||
validations: state.values.validationHistory,
|
||||
// Only include templates_selected when templates were actually used
|
||||
...(state.values.templateIds.length > 0 && {
|
||||
templates_selected: state.values.templateIds,
|
||||
}),
|
||||
};
|
||||
|
||||
this.onTelemetryEvent('Builder replied to user message', properties);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ export const MAX_WORKFLOW_LENGTH_TOKENS = 30_000;
|
|||
*/
|
||||
export const AVG_CHARS_PER_TOKEN_ANTHROPIC = 3.5;
|
||||
|
||||
/**
|
||||
* Maximum characters allowed for a single node example configuration.
|
||||
* Examples exceeding this limit are filtered out to avoid context bloat.
|
||||
* Based on ~5000 tokens at AVG_CHARS_PER_TOKEN_ANTHROPIC ratio.
|
||||
*/
|
||||
export const MAX_NODE_EXAMPLE_CHARS = 5000 * AVG_CHARS_PER_TOKEN_ANTHROPIC;
|
||||
|
||||
/**
|
||||
* Maximum iterations for subgraph tool loops.
|
||||
* Prevents infinite loops when agents keep calling tools without finishing.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ jest.mock('@/tools/prompts/main-agent.prompt', () => ({
|
|||
mainAgentPrompt: {
|
||||
invoke: jest.fn().mockResolvedValue('mocked prompt'),
|
||||
},
|
||||
createMainAgentPrompt: jest.fn().mockReturnValue({
|
||||
invoke: jest.fn().mockResolvedValue('mocked prompt'),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@/utils/operations-processor', () => ({
|
||||
processOperations: jest.fn(),
|
||||
|
|
@ -64,6 +67,7 @@ Object.defineProperty(global, 'crypto', {
|
|||
|
||||
import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
||||
import { ValidationError } from '@/errors';
|
||||
import { createMainAgentPrompt } from '@/tools/prompts/main-agent.prompt';
|
||||
import type { StreamOutput } from '@/types/streaming';
|
||||
import { createStreamProcessor } from '@/utils/stream-processor';
|
||||
import {
|
||||
|
|
@ -289,6 +293,8 @@ describe('WorkflowBuilderAgent', () => {
|
|||
validationHistory: [],
|
||||
techniqueCategories: [],
|
||||
previousSummary: 'EMPTY',
|
||||
nodeConfigurations: {},
|
||||
templateIds: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -463,4 +469,71 @@ describe('WorkflowBuilderAgent', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature flags', () => {
|
||||
const mockCreateMainAgentPrompt = createMainAgentPrompt as jest.MockedFunction<
|
||||
typeof createMainAgentPrompt
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateMainAgentPrompt.mockClear();
|
||||
});
|
||||
|
||||
it('should pass includeExamplesPhase: true when templateExamples flag is enabled', async () => {
|
||||
const mockStreamOutput: StreamOutput = {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Processing...' }],
|
||||
};
|
||||
const mockAsyncGenerator = (async function* () {
|
||||
yield mockStreamOutput;
|
||||
})();
|
||||
(createStreamProcessor as jest.MockedFunction<typeof createStreamProcessor>).mockReturnValue(
|
||||
mockAsyncGenerator,
|
||||
);
|
||||
|
||||
const generator = agent.chat({
|
||||
message: 'Create a workflow',
|
||||
featureFlags: { templateExamples: true },
|
||||
});
|
||||
await generator.next();
|
||||
|
||||
expect(mockCreateMainAgentPrompt).toHaveBeenCalledWith({ includeExamplesPhase: true });
|
||||
});
|
||||
|
||||
it('should pass includeExamplesPhase: false when templateExamples flag is disabled', async () => {
|
||||
const mockStreamOutput: StreamOutput = {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Processing...' }],
|
||||
};
|
||||
const mockAsyncGenerator = (async function* () {
|
||||
yield mockStreamOutput;
|
||||
})();
|
||||
(createStreamProcessor as jest.MockedFunction<typeof createStreamProcessor>).mockReturnValue(
|
||||
mockAsyncGenerator,
|
||||
);
|
||||
|
||||
const generator = agent.chat({
|
||||
message: 'Create a workflow',
|
||||
featureFlags: { templateExamples: false },
|
||||
});
|
||||
await generator.next();
|
||||
|
||||
expect(mockCreateMainAgentPrompt).toHaveBeenCalledWith({ includeExamplesPhase: false });
|
||||
});
|
||||
|
||||
it('should pass includeExamplesPhase: false when featureFlags is not provided', async () => {
|
||||
const mockStreamOutput: StreamOutput = {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Processing...' }],
|
||||
};
|
||||
const mockAsyncGenerator = (async function* () {
|
||||
yield mockStreamOutput;
|
||||
})();
|
||||
(createStreamProcessor as jest.MockedFunction<typeof createStreamProcessor>).mockReturnValue(
|
||||
mockAsyncGenerator,
|
||||
);
|
||||
|
||||
const generator = agent.chat({ message: 'Create a workflow' });
|
||||
await generator.next();
|
||||
|
||||
expect(mockCreateMainAgentPrompt).toHaveBeenCalledWith({ includeExamplesPhase: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@ import type { Logger } from '@n8n/backend-common';
|
|||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
|
||||
|
||||
import { createAddNodeTool, getAddNodeToolBase } from './add-node.tool';
|
||||
import { CATEGORIZE_PROMPT_TOOL, createCategorizePromptTool } from './categorize-prompt.tool';
|
||||
import { CONNECT_NODES_TOOL, createConnectNodesTool } from './connect-nodes.tool';
|
||||
import { createGetBestPracticesTool, GET_BEST_PRACTICES_TOOL } from './get-best-practices.tool';
|
||||
import { createGetNodeParameterTool, GET_NODE_PARAMETER_TOOL } from './get-node-parameter.tool';
|
||||
import {
|
||||
createGetWorkflowExamplesTool,
|
||||
GET_WORKFLOW_EXAMPLES_TOOL,
|
||||
} from './get-workflow-examples.tool';
|
||||
import { createNodeDetailsTool, NODE_DETAILS_TOOL } from './node-details.tool';
|
||||
import { createNodeSearchTool, NODE_SEARCH_TOOL } from './node-search.tool';
|
||||
import { createRemoveConnectionTool, REMOVE_CONNECTION_TOOL } from './remove-connection.tool';
|
||||
|
|
@ -24,15 +29,27 @@ export function getBuilderTools({
|
|||
logger,
|
||||
llmComplexTask,
|
||||
instanceUrl,
|
||||
featureFlags,
|
||||
}: {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
llmComplexTask: BaseChatModel;
|
||||
logger?: Logger;
|
||||
instanceUrl?: string;
|
||||
featureFlags?: BuilderFeatureFlags;
|
||||
}): BuilderTool[] {
|
||||
return [
|
||||
const tools: BuilderTool[] = [
|
||||
createCategorizePromptTool(llmComplexTask, logger),
|
||||
createGetBestPracticesTool(),
|
||||
];
|
||||
|
||||
// Conditionally add workflow examples tool based on feature flag
|
||||
// Only enabled when flag is explicitly true
|
||||
if (featureFlags?.templateExamples === true) {
|
||||
tools.push(createGetWorkflowExamplesTool(logger));
|
||||
}
|
||||
|
||||
// Add remaining tools
|
||||
tools.push(
|
||||
createNodeSearchTool(parsedNodeTypes),
|
||||
createNodeDetailsTool(parsedNodeTypes),
|
||||
createAddNodeTool(parsedNodeTypes),
|
||||
|
|
@ -42,7 +59,9 @@ export function getBuilderTools({
|
|||
createUpdateNodeParametersTool(parsedNodeTypes, llmComplexTask, logger, instanceUrl),
|
||||
createGetNodeParameterTool(),
|
||||
createValidateWorkflowTool(parsedNodeTypes, logger),
|
||||
];
|
||||
);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -52,10 +71,21 @@ export function getBuilderTools({
|
|||
*/
|
||||
export function getBuilderToolsForDisplay({
|
||||
nodeTypes,
|
||||
}: { nodeTypes: INodeTypeDescription[] }): BuilderToolBase[] {
|
||||
return [
|
||||
CATEGORIZE_PROMPT_TOOL,
|
||||
GET_BEST_PRACTICES_TOOL,
|
||||
featureFlags,
|
||||
}: {
|
||||
nodeTypes: INodeTypeDescription[];
|
||||
featureFlags?: BuilderFeatureFlags;
|
||||
}): BuilderToolBase[] {
|
||||
const tools: BuilderToolBase[] = [CATEGORIZE_PROMPT_TOOL, GET_BEST_PRACTICES_TOOL];
|
||||
|
||||
// Conditionally add workflow examples tool based on feature flag
|
||||
// Only enabled when flag is explicitly true
|
||||
if (featureFlags?.templateExamples === true) {
|
||||
tools.push(GET_WORKFLOW_EXAMPLES_TOOL);
|
||||
}
|
||||
|
||||
// Add remaining tools
|
||||
tools.push(
|
||||
NODE_SEARCH_TOOL,
|
||||
NODE_DETAILS_TOOL,
|
||||
getAddNodeToolBase(nodeTypes),
|
||||
|
|
@ -65,5 +95,7 @@ export function getBuilderToolsForDisplay({
|
|||
UPDATING_NODE_PARAMETER_TOOL,
|
||||
GET_NODE_PARAMETER_TOOL,
|
||||
VALIDATE_WORKFLOW_TOOL,
|
||||
];
|
||||
);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
import { tool } from '@langchain/core/tools';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GetWorkflowExamplesOutput, WorkflowMetadata } from '@/types';
|
||||
import type { BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import {
|
||||
createProgressReporter,
|
||||
createBatchProgressReporter,
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
} from './helpers';
|
||||
import { processWorkflowExamples } from './utils/markdown-workflow.utils';
|
||||
import { fetchTemplateList, fetchTemplateByID } from './web/templates';
|
||||
|
||||
/**
|
||||
* Workflow example query schema
|
||||
*/
|
||||
const workflowExampleQuerySchema = z.object({
|
||||
search: z.string().optional().describe('Search term to find workflow examples'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Main schema for get workflow examples tool
|
||||
*/
|
||||
const getWorkflowExamplesSchema = z.object({
|
||||
queries: z
|
||||
.array(workflowExampleQuerySchema)
|
||||
.min(1)
|
||||
.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
|
||||
*/
|
||||
function buildQueryIdentifier(query: {
|
||||
search?: string;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (query.search) {
|
||||
parts.push(`search: ${query.search}`);
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the response message from search results
|
||||
*/
|
||||
function buildResponseMessage(output: GetWorkflowExamplesOutput): string {
|
||||
if (output.examples.length === 0) {
|
||||
return 'No workflow examples found';
|
||||
}
|
||||
|
||||
const sections: string[] = [`Found ${output.totalResults} workflow example(s):`];
|
||||
|
||||
for (const example of output.examples) {
|
||||
sections.push(`\n## ${example.name}`);
|
||||
if (example.description) {
|
||||
sections.push(example.description);
|
||||
}
|
||||
sections.push(example.workflow);
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
export const GET_WORKFLOW_EXAMPLES_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_workflow_examples',
|
||||
displayTitle: 'Retrieving workflow examples',
|
||||
};
|
||||
|
||||
/** Tool description */
|
||||
const TOOL_DESCRIPTION = `Retrieve workflow examples from n8n's workflow library to use as reference for building workflows.
|
||||
|
||||
This tool searches for existing workflow examples that match specific criteria.
|
||||
The retrieved workflows serve as reference material to understand common patterns, node usage and connections.
|
||||
Consider these workflows as ideal solutions.
|
||||
The workflows will be returned in a token efficient format rather than JSON.
|
||||
|
||||
Usage:
|
||||
- Provide search criteria to find relevant workflow examples
|
||||
- Results include workflow metadata, summaries, and full workflow data for reference
|
||||
|
||||
Parameters:
|
||||
- search: Keywords to search for in workflow names/descriptions based on the user prompt`;
|
||||
|
||||
/**
|
||||
* Factory function to create the get workflow examples tool
|
||||
*/
|
||||
export function createGetWorkflowExamplesTool(logger?: Logger) {
|
||||
const dynamicTool = tool(
|
||||
async (input, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
GET_WORKFLOW_EXAMPLES_TOOL.toolName,
|
||||
GET_WORKFLOW_EXAMPLES_TOOL.displayTitle,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
const validatedInput = getWorkflowExamplesSchema.parse(input);
|
||||
const { queries } = validatedInput;
|
||||
|
||||
// Report tool start
|
||||
reporter.start(validatedInput);
|
||||
|
||||
let allResults: WorkflowMetadata[] = [];
|
||||
let allTemplateIds: number[] = [];
|
||||
|
||||
// Create batch reporter for progress tracking
|
||||
const batchReporter = createBatchProgressReporter(reporter, 'Retrieving workflow examples');
|
||||
batchReporter.init(queries.length);
|
||||
|
||||
// Process each query
|
||||
for (const query of queries) {
|
||||
const identifier = buildQueryIdentifier(query);
|
||||
|
||||
try {
|
||||
// Report progress
|
||||
batchReporter.next(identifier);
|
||||
|
||||
// Fetch workflow examples
|
||||
const result = await fetchWorkflowExamples(query, logger);
|
||||
|
||||
// Add to results
|
||||
allResults = allResults.concat(result.workflows);
|
||||
allTemplateIds = allTemplateIds.concat(result.templateIds);
|
||||
} catch (error) {
|
||||
logger?.error('Error fetching workflow examples', { error });
|
||||
}
|
||||
}
|
||||
|
||||
// Complete batch reporting
|
||||
batchReporter.complete();
|
||||
|
||||
// Deduplicate results based on workflow name
|
||||
const uniqueWorkflows = new Map<string, WorkflowMetadata>();
|
||||
for (const workflow of allResults) {
|
||||
if (!uniqueWorkflows.has(workflow.name)) {
|
||||
uniqueWorkflows.set(workflow.name, workflow);
|
||||
}
|
||||
}
|
||||
const deduplicatedResults = Array.from(uniqueWorkflows.values());
|
||||
|
||||
// Process workflows to get mermaid diagrams and collect node configurations in one pass
|
||||
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,
|
||||
description: workflow.description,
|
||||
workflow: processedResults[index].mermaid,
|
||||
}));
|
||||
const output: GetWorkflowExamplesOutput = {
|
||||
examples: formattedResults,
|
||||
totalResults: deduplicatedResults.length,
|
||||
nodeConfigurations,
|
||||
};
|
||||
|
||||
// Build response message and report
|
||||
const responseMessage = buildResponseMessage(output);
|
||||
reporter.complete(output);
|
||||
|
||||
// Deduplicate template IDs
|
||||
const uniqueTemplateIds = [...new Set(allTemplateIds)];
|
||||
|
||||
// Return success response with node configurations and template IDs stored in state
|
||||
return createSuccessResponse(config, responseMessage, {
|
||||
nodeConfigurations,
|
||||
templateIds: uniqueTemplateIds,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle validation or unexpected errors
|
||||
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: GET_WORKFLOW_EXAMPLES_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: GET_WORKFLOW_EXAMPLES_TOOL.toolName,
|
||||
description: TOOL_DESCRIPTION,
|
||||
schema: getWorkflowExamplesSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
...GET_WORKFLOW_EXAMPLES_TOOL,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import { tool } from '@langchain/core/tools';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { INodeParameters, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MAX_NODE_EXAMPLE_CHARS } from '@/constants';
|
||||
import type { BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import { createProgressReporter, reportProgress } from './helpers/progress';
|
||||
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';
|
||||
|
|
@ -76,6 +78,7 @@ function formatNodeDetails(
|
|||
details: NodeDetails,
|
||||
withParameters: boolean = false,
|
||||
withConnections: boolean = true,
|
||||
examples: INodeParameters[] = [],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
|
|
@ -105,6 +108,27 @@ function formatNodeDetails(
|
|||
parts.push('</connections>');
|
||||
}
|
||||
|
||||
// Example configurations from workflow examples (with token limit)
|
||||
if (examples.length > 0) {
|
||||
const { parts: exampleParts } = examples.reduce<{ parts: string[]; chars: number }>(
|
||||
(acc, example) => {
|
||||
const exampleStr = JSON.stringify(example, null, 2);
|
||||
if (acc.chars + exampleStr.length <= MAX_NODE_EXAMPLE_CHARS) {
|
||||
acc.parts.push(exampleStr);
|
||||
acc.chars += exampleStr.length;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ parts: [], chars: 0 },
|
||||
);
|
||||
|
||||
if (exampleParts.length > 0) {
|
||||
parts.push('<node_examples>');
|
||||
parts.push(...exampleParts);
|
||||
parts.push('</node_examples>');
|
||||
}
|
||||
}
|
||||
|
||||
parts.push('</node_details>');
|
||||
|
||||
return parts.join('\n');
|
||||
|
|
@ -165,8 +189,21 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
|||
// Extract node details
|
||||
const details = extractNodeDetails(nodeType);
|
||||
|
||||
// Format the output message
|
||||
const message = formatNodeDetails(details, withParameters, withConnections);
|
||||
// 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 = [];
|
||||
}
|
||||
|
||||
// Format the output message with examples
|
||||
const message = formatNodeDetails(details, withParameters, withConnections, examples);
|
||||
|
||||
// Report completion
|
||||
const output: NodeDetailsOutput = {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,125 @@ import { ChatPromptTemplate } from '@langchain/core/prompts';
|
|||
|
||||
import { instanceUrlPrompt } from '../../chains/prompts/instance-url';
|
||||
|
||||
const systemPrompt = `You are an AI assistant specialized in creating and editing n8n workflows. Your goal is to help users build efficient, well-connected workflows by intelligently using the available tools.
|
||||
/**
|
||||
* Phase configuration for the workflow creation sequence
|
||||
*/
|
||||
interface PhaseConfig {
|
||||
name: string;
|
||||
metadata?: string; // e.g., "- MANDATORY", "(parallel execution)"
|
||||
content: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating the main agent prompt
|
||||
*/
|
||||
export interface MainAgentPromptOptions {
|
||||
includeExamplesPhase?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the workflow creation phases with dynamic numbering
|
||||
*/
|
||||
function generateWorkflowCreationPhases(options: MainAgentPromptOptions = {}): string {
|
||||
const { includeExamplesPhase = false } = options;
|
||||
|
||||
const phases: PhaseConfig[] = [
|
||||
{
|
||||
name: 'Categorization Phase',
|
||||
metadata: '- MANDATORY',
|
||||
content: [
|
||||
'Categorize the prompt and search for best practices documentation based on the techniques found',
|
||||
'Why: Best practices help to inform which nodes to search for and use to build the workflow plus mistakes to avoid',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (includeExamplesPhase) {
|
||||
phases.push({
|
||||
name: 'Examples Phase',
|
||||
metadata: '(parallel execution)',
|
||||
content: [
|
||||
'Search for workflow examples using simple, relevant search terms',
|
||||
'Why: Examples provide complete, working implementations showing nodes, connections and parameter configurations',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
phases.push(
|
||||
{
|
||||
name: 'Discovery Phase',
|
||||
metadata: '(parallel execution)',
|
||||
content: [
|
||||
'Search for all required node types simultaneously, review the <node_selection> section for tips and best practices',
|
||||
'Why: Ensures you work with actual available nodes, not assumptions',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Analysis Phase',
|
||||
metadata: '(parallel execution)',
|
||||
content: [
|
||||
'Get details for ALL nodes before proceeding',
|
||||
'Why: Understanding inputs/outputs prevents connection errors and ensures proper parameter configuration',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Creation Phase',
|
||||
metadata: '(parallel execution)',
|
||||
content: [
|
||||
'Add nodes individually by calling add_nodes for each node',
|
||||
'Execute multiple add_nodes calls in parallel for efficiency',
|
||||
'Why: Each node addition is independent, parallel execution is faster, and the operations processor ensures consistency',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Connection Phase',
|
||||
metadata: '(parallel execution)',
|
||||
content: [
|
||||
'Connect all nodes based on discovered input/output structure',
|
||||
'Why: Parallel connections are safe and faster',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Configuration Phase',
|
||||
metadata: '(parallel execution) - MANDATORY',
|
||||
content: [
|
||||
'ALWAYS configure nodes using update_node_parameters',
|
||||
'Even for "simple" nodes like HTTP Request, Set, etc.',
|
||||
'Configure all nodes in parallel for efficiency',
|
||||
'Why: Unconfigured nodes will fail at runtime',
|
||||
'Pay special attention to parameters that control node behavior (dataType, mode, operation)',
|
||||
'Why: Unconfigured nodes will fail at runtime, defaults are unreliable',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Validation Phase',
|
||||
metadata: '(tool call) - MANDATORY',
|
||||
content: [
|
||||
'Run validate_workflow after applying changes to refresh the workflow validation report',
|
||||
'Review <workflow_validation_report> and resolve any violations before finalizing',
|
||||
'Why: Ensures structural issues are surfaced early; rerun validation after major updates',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Generate numbered output
|
||||
return phases
|
||||
.map((phase, index) => {
|
||||
const phaseNumber = index + 1;
|
||||
const metadataStr = phase.metadata ? ` ${phase.metadata}` : '';
|
||||
const contentStr = phase.content.map((line) => ` - ${line}`).join('\n');
|
||||
return `${phaseNumber}. **${phase.name}**${metadataStr}\n${contentStr}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full system prompt with dynamic phases
|
||||
*/
|
||||
function generateSystemPrompt(options: MainAgentPromptOptions = {}): string {
|
||||
const workflowPhases = generateWorkflowCreationPhases(options);
|
||||
|
||||
return `You are an AI assistant specialized in creating and editing n8n workflows. Your goal is to help users build efficient, well-connected workflows by intelligently using the available tools.
|
||||
<core_principle>
|
||||
After receiving tool results, reflect on their quality and determine optimal next steps. Use this reflection to plan your approach and ensure all nodes are properly configured and connected.
|
||||
</core_principle>
|
||||
|
|
@ -32,39 +150,7 @@ The system's operations processor ensures state consistency across all parallel
|
|||
<workflow_creation_sequence>
|
||||
Follow this proven sequence for creating robust workflows:
|
||||
|
||||
1. **Categorization Phase** - MANDATORY
|
||||
- Categorize the prompt and search for best practices documentation based on the techniques found
|
||||
- Why: Best practices help to inform which nodes to search for and use to build the workflow plus mistakes to avoid
|
||||
|
||||
2. **Discovery Phase** (parallel execution)
|
||||
- Search for all required node types simultaneously, review the <node_selection> section for tips and best practices
|
||||
- Why: Ensures you work with actual available nodes, not assumptions
|
||||
|
||||
3. **Analysis Phase** (parallel execution)
|
||||
- Get details for ALL nodes before proceeding
|
||||
- Why: Understanding inputs/outputs prevents connection errors and ensures proper parameter configuration
|
||||
|
||||
4. **Creation Phase** (parallel execution)
|
||||
- Add nodes individually by calling add_nodes for each node
|
||||
- Execute multiple add_nodes calls in parallel for efficiency
|
||||
- Why: Each node addition is independent, parallel execution is faster, and the operations processor ensures consistency
|
||||
|
||||
5. **Connection Phase** (parallel execution)
|
||||
- Connect all nodes based on discovered input/output structure
|
||||
- Why: Parallel connections are safe and faster
|
||||
|
||||
6. **Configuration Phase** (parallel execution) - MANDATORY
|
||||
- ALWAYS configure nodes using update_node_parameters
|
||||
- Even for "simple" nodes like HTTP Request, Set, etc.
|
||||
- Configure all nodes in parallel for efficiency
|
||||
- Why: Unconfigured nodes will fail at runtime
|
||||
- Pay special attention to parameters that control node behavior (dataType, mode, operation)
|
||||
- Why: Unconfigured nodes will fail at runtime, defaults are unreliable
|
||||
|
||||
6. **Validation Phase** (tool call) - MANDATORY
|
||||
- Run validate_workflow after applying changes to refresh the workflow validation report
|
||||
- Review <workflow_validation_report> and resolve any violations before finalizing
|
||||
- Why: Ensures structural issues are surfaced early; rerun validation after major updates
|
||||
${workflowPhases}
|
||||
|
||||
<node_selection>
|
||||
When building AI workflows prefer the AI agent node to other text LLM nodes, unless the user specifies them by name. Summarization, analysis, information
|
||||
|
|
@ -482,6 +568,7 @@ update_node_parameters({{
|
|||
</handling_uncertainty>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
const responsePatterns = `
|
||||
<response_patterns>
|
||||
|
|
@ -529,28 +616,41 @@ const previousConversationSummary = `
|
|||
{previousSummary}
|
||||
</previous_summary>`;
|
||||
|
||||
export const mainAgentPrompt = ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
'system',
|
||||
/**
|
||||
* Factory function to create the main agent prompt with configurable options
|
||||
*/
|
||||
export function createMainAgentPrompt(options: MainAgentPromptOptions = {}) {
|
||||
const systemPrompt = generateSystemPrompt(options);
|
||||
|
||||
return ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: systemPrompt,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: instanceUrlPrompt,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: responsePatterns,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: previousConversationSummary,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
'system',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: systemPrompt,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: instanceUrlPrompt,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: responsePatterns,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: previousConversationSummary,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
['placeholder', '{messages}'],
|
||||
]);
|
||||
['placeholder', '{messages}'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default main agent prompt (backwards compatible export)
|
||||
* Includes all phases by default
|
||||
*/
|
||||
export const mainAgentPrompt = createMainAgentPrompt();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import { createCategorizePromptTool, CATEGORIZE_PROMPT_TOOL } from '../categoriz
|
|||
import { CONNECT_NODES_TOOL, createConnectNodesTool } from '../connect-nodes.tool';
|
||||
import { GET_BEST_PRACTICES_TOOL, createGetBestPracticesTool } from '../get-best-practices.tool';
|
||||
import { createGetNodeParameterTool, GET_NODE_PARAMETER_TOOL } from '../get-node-parameter.tool';
|
||||
import {
|
||||
createGetWorkflowExamplesTool,
|
||||
GET_WORKFLOW_EXAMPLES_TOOL,
|
||||
} from '../get-workflow-examples.tool';
|
||||
import { createNodeDetailsTool, NODE_DETAILS_TOOL } from '../node-details.tool';
|
||||
import { createNodeSearchTool, NODE_SEARCH_TOOL } from '../node-search.tool';
|
||||
import { createRemoveNodeTool, REMOVE_NODE_TOOL } from '../remove-node.tool';
|
||||
|
|
@ -43,6 +47,17 @@ jest.mock('../get-best-practices.tool', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../get-workflow-examples.tool', () => ({
|
||||
GET_WORKFLOW_EXAMPLES_TOOL: {
|
||||
toolName: 'get_workflow_examples',
|
||||
displayTitle: 'Retrieving workflow examples',
|
||||
},
|
||||
createGetWorkflowExamplesTool: jest.fn().mockReturnValue({
|
||||
name: 'getWorkflowExamplesTool',
|
||||
tool: { name: 'getWorkflowExamplesTool' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../add-node.tool', () => ({
|
||||
createAddNodeTool: jest.fn().mockReturnValue({
|
||||
name: 'addNodeTool',
|
||||
|
|
@ -155,17 +170,19 @@ describe('builder-tools', () => {
|
|||
});
|
||||
|
||||
describe('getBuilderTools', () => {
|
||||
it('should return all builder tools in the correct order', () => {
|
||||
it('should return all builder tools including workflow examples when feature flag is enabled', () => {
|
||||
const tools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
logger: mockLogger,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
instanceUrl: 'https://test.n8n.io',
|
||||
featureFlags: { templateExamples: true },
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(11);
|
||||
expect(tools).toHaveLength(12);
|
||||
expect(createCategorizePromptTool).toHaveBeenCalledWith(mockLlmComplexTask, mockLogger);
|
||||
expect(createGetBestPracticesTool).toHaveBeenCalled();
|
||||
expect(createGetWorkflowExamplesTool).toHaveBeenCalledWith(mockLogger);
|
||||
expect(createNodeSearchTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(createNodeDetailsTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(createAddNodeTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
|
|
@ -182,22 +199,37 @@ describe('builder-tools', () => {
|
|||
expect(createValidateWorkflowTool).toHaveBeenCalledWith(parsedNodeTypes, mockLogger);
|
||||
});
|
||||
|
||||
it('should work without optional parameters', () => {
|
||||
it('should exclude workflow examples tool when feature flag is disabled', () => {
|
||||
const tools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
logger: mockLogger,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
featureFlags: { templateExamples: false },
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(11);
|
||||
expect(createGetWorkflowExamplesTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should exclude workflow examples tool when feature flag is not provided', () => {
|
||||
const tools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(11);
|
||||
expect(createConnectNodesTool).toHaveBeenCalledWith(parsedNodeTypes, undefined);
|
||||
expect(createRemoveNodeTool).toHaveBeenCalledWith(undefined);
|
||||
expect(createUpdateNodeParametersTool).toHaveBeenCalledWith(
|
||||
expect(createGetWorkflowExamplesTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should exclude workflow examples tool when featureFlags is undefined', () => {
|
||||
const tools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
mockLlmComplexTask,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(createValidateWorkflowTool).toHaveBeenCalledWith(parsedNodeTypes, undefined);
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
featureFlags: undefined,
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(11);
|
||||
expect(createGetWorkflowExamplesTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass through different node types', () => {
|
||||
|
|
@ -219,23 +251,44 @@ describe('builder-tools', () => {
|
|||
});
|
||||
|
||||
describe('getBuilderToolsForDisplay', () => {
|
||||
it('should return all display tools in the correct order', () => {
|
||||
it('should return all display tools including workflow examples when feature flag is enabled', () => {
|
||||
const tools = getBuilderToolsForDisplay({
|
||||
nodeTypes: parsedNodeTypes,
|
||||
featureFlags: { templateExamples: true },
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(12);
|
||||
expect(tools[0]).toBe(CATEGORIZE_PROMPT_TOOL);
|
||||
expect(tools[1]).toBe(GET_BEST_PRACTICES_TOOL);
|
||||
expect(tools[2]).toBe(GET_WORKFLOW_EXAMPLES_TOOL);
|
||||
expect(tools[3]).toBe(NODE_SEARCH_TOOL);
|
||||
expect(tools[4]).toBe(NODE_DETAILS_TOOL);
|
||||
expect(tools[6]).toBe(CONNECT_NODES_TOOL);
|
||||
expect(tools[7]).toBe(REMOVE_CONNECTION_TOOL);
|
||||
expect(tools[8]).toBe(REMOVE_NODE_TOOL);
|
||||
expect(tools[9]).toBe(UPDATING_NODE_PARAMETER_TOOL);
|
||||
expect(tools[10]).toBe(GET_NODE_PARAMETER_TOOL);
|
||||
expect(tools[11]).toBe(VALIDATE_WORKFLOW_TOOL);
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
});
|
||||
|
||||
it('should exclude workflow examples tool when feature flag is disabled', () => {
|
||||
const tools = getBuilderToolsForDisplay({
|
||||
nodeTypes: parsedNodeTypes,
|
||||
featureFlags: { templateExamples: false },
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(11);
|
||||
expect(tools).not.toContain(GET_WORKFLOW_EXAMPLES_TOOL);
|
||||
});
|
||||
|
||||
it('should exclude workflow examples tool when feature flag is not provided', () => {
|
||||
const tools = getBuilderToolsForDisplay({
|
||||
nodeTypes: parsedNodeTypes,
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(11);
|
||||
expect(tools[0]).toBe(CATEGORIZE_PROMPT_TOOL);
|
||||
expect(tools[1]).toBe(GET_BEST_PRACTICES_TOOL);
|
||||
expect(tools[2]).toBe(NODE_SEARCH_TOOL);
|
||||
expect(tools[3]).toBe(NODE_DETAILS_TOOL);
|
||||
expect(tools[5]).toBe(CONNECT_NODES_TOOL);
|
||||
expect(tools[6]).toBe(REMOVE_CONNECTION_TOOL);
|
||||
expect(tools[7]).toBe(REMOVE_NODE_TOOL);
|
||||
expect(tools[8]).toBe(UPDATING_NODE_PARAMETER_TOOL);
|
||||
expect(tools[9]).toBe(GET_NODE_PARAMETER_TOOL);
|
||||
expect(tools[10]).toBe(VALIDATE_WORKFLOW_TOOL);
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(tools).not.toContain(GET_WORKFLOW_EXAMPLES_TOOL);
|
||||
});
|
||||
|
||||
it('should work with empty node types array', () => {
|
||||
|
|
@ -262,15 +315,33 @@ describe('builder-tools', () => {
|
|||
});
|
||||
|
||||
describe('consistency between getBuilderTools and getBuilderToolsForDisplay', () => {
|
||||
it('should return the same number of tools', () => {
|
||||
it('should return the same number of tools when feature flag is enabled', () => {
|
||||
const builderTools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
logger: mockLogger,
|
||||
featureFlags: { templateExamples: true },
|
||||
});
|
||||
|
||||
const displayTools = getBuilderToolsForDisplay({
|
||||
nodeTypes: parsedNodeTypes,
|
||||
featureFlags: { templateExamples: true },
|
||||
});
|
||||
|
||||
expect(builderTools).toHaveLength(displayTools.length);
|
||||
});
|
||||
|
||||
it('should return the same number of tools when feature flag is disabled', () => {
|
||||
const builderTools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
logger: mockLogger,
|
||||
featureFlags: { templateExamples: false },
|
||||
});
|
||||
|
||||
const displayTools = getBuilderToolsForDisplay({
|
||||
nodeTypes: parsedNodeTypes,
|
||||
featureFlags: { templateExamples: false },
|
||||
});
|
||||
|
||||
expect(builderTools).toHaveLength(displayTools.length);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
parseToolResult,
|
||||
extractProgressMessages,
|
||||
findProgressMessage,
|
||||
createToolConfigWithWriter,
|
||||
createToolConfig,
|
||||
expectToolSuccess,
|
||||
type ParsedToolContent,
|
||||
createNode,
|
||||
} from '../../../test/test-utils';
|
||||
import type { TemplateWorkflowDescription, TemplateFetchResponse } from '../../types/web/templates';
|
||||
import { createGetWorkflowExamplesTool } from '../get-workflow-examples.tool';
|
||||
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');
|
||||
|
||||
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
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getWorkflowExamplesTool = createGetWorkflowExamplesTool().tool;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Helper to create mock workflow nodes
|
||||
const createMockWorkflowNodes = (count: number = 3): INode[] => {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createNode({
|
||||
id: `node-${i}`,
|
||||
name: `Node ${i}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to create mock template workflow description
|
||||
const createMockTemplateDescription = (
|
||||
id: number,
|
||||
name: string,
|
||||
description: string,
|
||||
): TemplateWorkflowDescription => ({
|
||||
id,
|
||||
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: {},
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
queries: [{ search: 'email automation' }],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'Found 2 workflow example(s)');
|
||||
expect(message).toContain('Email Automation');
|
||||
expect(message).toContain('Automate email workflows');
|
||||
expect(message).toContain('Slack Notification');
|
||||
expect(message).toContain('Send Slack notifications');
|
||||
// Verify mermaid diagrams are included
|
||||
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);
|
||||
|
||||
// Check progress messages
|
||||
const progressCalls = extractProgressMessages(mockConfig.writer);
|
||||
expect(progressCalls.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const startMessage = findProgressMessage(progressCalls, 'running', 'input');
|
||||
expect(startMessage).toBeDefined();
|
||||
|
||||
const completeMessage = findProgressMessage(progressCalls, 'completed');
|
||||
expect(completeMessage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle multiple queries and combine results', async () => {
|
||||
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));
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
queries: [{ search: 'database' }, { search: 'api' }],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
expectToolSuccess(content, 'Found 2 workflow example');
|
||||
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' });
|
||||
});
|
||||
|
||||
it('should return no results message when no workflows found', async () => {
|
||||
const mockConfig = createToolConfig('get_workflow_examples', 'test-call-4');
|
||||
|
||||
mockFetchTemplateList.mockResolvedValue({
|
||||
workflows: [],
|
||||
totalWorkflows: 0,
|
||||
});
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
queries: [{ search: 'nonexistent workflow' }],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
expectToolSuccess(content, 'No workflow examples found');
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
queries: [{ search: 'test' }],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
// Should return 2 successful workflows (ignoring the failed one)
|
||||
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 () => {
|
||||
const mockConfig = createToolConfig('get_workflow_examples', 'test-call-6');
|
||||
|
||||
try {
|
||||
await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
queries: [],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(true).toBe(false);
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
expect(String(error)).toContain('Received tool input did not match expected schema');
|
||||
}
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
queries: [{ search: 'test' }],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
// Should return no results since the query failed
|
||||
expectToolSuccess(content, 'No workflow examples found');
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
queries: [{ search: 'query1' }, { search: 'query2' }],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const progressCalls = extractProgressMessages(mockConfig.writer);
|
||||
|
||||
// Should have progress messages for batch processing
|
||||
const progressMessages = progressCalls.filter(
|
||||
(msg) => msg.status === 'running' && msg.updates.some((u) => u.type === 'progress'),
|
||||
);
|
||||
expect(progressMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for batch-related progress messages
|
||||
const batchMessages = progressMessages.filter((msg) =>
|
||||
msg.updates.some(
|
||||
(u) =>
|
||||
typeof u.data?.message === 'string' &&
|
||||
u.data.message.includes('Retrieving workflow examples'),
|
||||
),
|
||||
);
|
||||
expect(batchMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should continue processing remaining queries if one fails', async () => {
|
||||
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),
|
||||
);
|
||||
|
||||
const result = await getWorkflowExamplesTool.invoke(
|
||||
{
|
||||
queries: [{ search: 'failing query' }, { search: 'successful query' }],
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
|
||||
// Should return result from successful query
|
||||
expectToolSuccess(content, 'Found 1 workflow example(s)');
|
||||
expect(message).toContain('Success Workflow');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
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,494 @@
|
|||
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.start',
|
||||
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.start
|
||||
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.start',
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,554 @@
|
|||
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 = {
|
||||
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.',
|
||||
workflow: {
|
||||
name: 'Personal Life Manager with Telegram, Google Services & Voice-Enabled AI',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
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))',
|
||||
},
|
||||
},
|
||||
id: 'b70bab99-2919-42c0-a64f-ea8340503a81',
|
||||
name: 'Google Calendar',
|
||||
type: 'n8n-nodes-base.googleCalendarTool',
|
||||
position: [3232, 832],
|
||||
typeVersion: 1.1,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
sessionIdType: 'customKey',
|
||||
sessionKey: "={{ $('Listen for incoming events').first().json.message.from.id }}",
|
||||
},
|
||||
id: '621a4839-bc0d-4c73-b228-3831ad50ca3c',
|
||||
name: 'Window Buffer Memory',
|
||||
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
position: [2016, 832],
|
||||
typeVersion: 1.2,
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
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') }}",
|
||||
},
|
||||
},
|
||||
id: '89d9a5d9-d3c7-48c1-98cf-cc8987ba9391',
|
||||
name: 'Get Email',
|
||||
type: 'n8n-nodes-base.gmailTool',
|
||||
position: [2800, 832],
|
||||
webhookId: 'a4ae7b5d-7686-4bee-a753-848932860b4e',
|
||||
typeVersion: 2.1,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
updates: ['message'],
|
||||
additionalFields: {},
|
||||
},
|
||||
id: '88f2bfc3-a997-4838-a4e6-911c60d377ec',
|
||||
name: 'Listen for incoming events',
|
||||
type: 'n8n-nodes-base.telegramTrigger',
|
||||
position: [880, 480],
|
||||
webhookId: '322dce18-f93e-4f86-b9b1-3305519b7834',
|
||||
typeVersion: 1,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
chatId: "={{ $('Listen for incoming events').first().json.message.from.id }}",
|
||||
text: '={{ $json.output }}',
|
||||
additionalFields: {
|
||||
appendAttribution: false,
|
||||
parse_mode: 'Markdown',
|
||||
},
|
||||
},
|
||||
id: 'fe37d04d-2bb4-4130-8386-665364195dce',
|
||||
name: 'Telegram',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
position: [2688, 464],
|
||||
webhookId: '2c133a40-af48-4106-bc1a-be6047840a89',
|
||||
typeVersion: 1.1,
|
||||
credentials: {},
|
||||
onError: 'continueErrorOutput',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
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: {},
|
||||
},
|
||||
id: 'a5717776-2c85-4dfb-9e05-bf9b805f9004',
|
||||
name: 'If',
|
||||
type: 'n8n-nodes-base.if',
|
||||
position: [1328, 480],
|
||||
typeVersion: 2.2,
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
fields: {
|
||||
values: [
|
||||
{
|
||||
name: 'text',
|
||||
stringValue: '={{ $json?.message?.text || "" }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
id: 'dc3d741a-2ed6-4c34-b14f-91a728b3fffd',
|
||||
name: 'Voice or Text',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [1104, 480],
|
||||
typeVersion: 3.2,
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
resource: 'file',
|
||||
fileId: "={{ $('Listen for incoming events').item.json.message.voice.file_id }}",
|
||||
additionalFields: {},
|
||||
},
|
||||
id: '7c7cbb13-8b9d-4e98-9287-4002166ff159',
|
||||
name: 'Get Voice File',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
position: [1552, 400],
|
||||
webhookId: 'ef3f120e-c212-45ff-99b5-b6a5a82598d8',
|
||||
typeVersion: 1.1,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content: '## Process Telegram Request\n',
|
||||
height: 279,
|
||||
width: 624,
|
||||
color: 7,
|
||||
},
|
||||
id: '8078f53c-0aed-4f01-bf8f-f0e65a8291c0',
|
||||
name: 'Sticky Note',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [1072, 368],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content:
|
||||
'\n\n\n\n\n\n\n\n\n\n\n\n1. [In OpenRouter](https://openrouter.ai/settings/keys) click **“Create API key”** and copy it.\n\n2. Open the ```OpenRouter``` node:\n * **Select Credential → Create New**\n * Paste into **API Key** and **Save**\n',
|
||||
height: 316,
|
||||
width: 294,
|
||||
color: 3,
|
||||
},
|
||||
id: '9006e460-0a4f-4250-876c-1743d7526909',
|
||||
name: 'Sticky Note1',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [1584, 784],
|
||||
typeVersion: 1,
|
||||
notes: '© 2025 Lucas Peyrin',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
options: {},
|
||||
},
|
||||
id: '7e0fa1ed-2cd6-48a6-bf04-d67d4d7fe842',
|
||||
name: 'OpenRouter',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
||||
position: [1680, 816],
|
||||
typeVersion: 1,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content:
|
||||
'\n\n\n\n\n\n\n\n\n\n\n\n\n\nThis node helps your agent remember the last few messages to stay on topic.',
|
||||
height: 260,
|
||||
width: 308,
|
||||
color: 7,
|
||||
},
|
||||
id: 'f326d185-cd53-421e-a3d1-ae3b0d162bfa',
|
||||
name: 'Sticky Note15',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [1904, 784],
|
||||
typeVersion: 1,
|
||||
notes: '© 2025 Lucas Peyrin',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content:
|
||||
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nThis node allows your agent create and get tasks from Google Tasks\n',
|
||||
height: 260,
|
||||
width: 484,
|
||||
color: 7,
|
||||
},
|
||||
id: '48c06490-e261-45f5-ad0c-2b2648203ab0',
|
||||
name: 'Sticky Note16',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [2240, 784],
|
||||
typeVersion: 1,
|
||||
notes: '© 2025 Lucas Peyrin',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content: '\n\n\n\n\n\n\n\n\n\n\n\n\n\nThis node allows your agent access your gmail\n',
|
||||
height: 260,
|
||||
width: 308,
|
||||
color: 7,
|
||||
},
|
||||
id: '8bb0d940-eda3-4ecf-8a1d-15b1a6445a83',
|
||||
name: 'Sticky Note18',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [2752, 784],
|
||||
typeVersion: 1,
|
||||
notes: '© 2025 Lucas Peyrin',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content:
|
||||
'\n\n\n\n\n\n\n\n\n\n\n\n\n\nThis node allows your agent access your Google calendar\n',
|
||||
height: 260,
|
||||
width: 404,
|
||||
color: 7,
|
||||
},
|
||||
id: 'cf8916e8-4701-4644-9d92-e2dd78665448',
|
||||
name: 'Sticky Note19',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [3088, 784],
|
||||
typeVersion: 1,
|
||||
notes: '© 2025 Lucas Peyrin',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content:
|
||||
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nUses OpenAI to convert voice to text.\n[In OpenAI](https://platform.openai.com/api-keys) click **“Create new secret key”** and copy it.',
|
||||
height: 276,
|
||||
width: 324,
|
||||
color: 7,
|
||||
},
|
||||
id: 'a5db0c52-ba8d-4622-8a0a-eae3a7f0d90f',
|
||||
name: 'Sticky Note20',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [1760, 368],
|
||||
typeVersion: 1,
|
||||
notes: '© 2025 Lucas Peyrin',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content:
|
||||
'Caylee, your peronal AI Assistant:\n1. Get email\n2. Check calendar\n3. Get and create to-do tasks \n\nEdit the **System Message** to adjust your agent’s thinking, behavior, and replies.\n\n\n\n\n\n\n\n\n\n\n',
|
||||
height: 380,
|
||||
width: 396,
|
||||
color: 7,
|
||||
},
|
||||
id: 'fd8b069a-19da-4740-a3ce-d88ee0e81331',
|
||||
name: 'Sticky Note13',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [2144, 272],
|
||||
typeVersion: 1,
|
||||
notes: '© 2025 Lucas Peyrin',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content:
|
||||
'# Try It Out!\n\nLaunch Jackie—your personal AI assistant that handles voice & text via Telegram to manage your digital life.\n\n**To get started:**\n\n1. **Connect all credentials** (Telegram, OpenAI, Gmail, etc.)\n2. **Activate the workflow** and message your Telegram bot:\n • "What emails do I have today?"\n • "Show me my calendar for tomorrow"\n • "Craete new to-do item"\n • 🎤 Send voice messages for hands-free interaction\n\n## Questions or Need Help?\n\nFor setup assistance, customization, or workflow support, join my Skool community!\n\n### [AI Automation Engineering Community](https://www.skool.com/ai-automation-engineering-3014)\n\nHappy learning! -- Derek Cheung\n',
|
||||
height: 568,
|
||||
width: 460,
|
||||
color: 4,
|
||||
},
|
||||
id: '1c27ac6c-39d7-4f07-8134-624e1cb21e07',
|
||||
name: 'Sticky Note3',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [368, 240],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content: '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nSend message back to Telegram\n',
|
||||
height: 288,
|
||||
width: 304,
|
||||
color: 7,
|
||||
},
|
||||
id: 'fd801aac-5dfa-4a51-abbd-b187a6e588e8',
|
||||
name: 'Sticky Note4',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [2592, 368],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
task: 'MTY1MTc5NzMxMzA5NDc5MTQ5NzQ6MDow',
|
||||
title: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Title', ``, 'string') }}",
|
||||
additionalFields: {},
|
||||
},
|
||||
id: '4a44f67a-65b1-4de7-898f-ee7724e33bb1',
|
||||
name: 'Create a task in Google Tasks',
|
||||
type: 'n8n-nodes-base.googleTasksTool',
|
||||
position: [2336, 848],
|
||||
typeVersion: 1,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
operation: 'getAll',
|
||||
task: 'MTY1MTc5NzMxMzA5NDc5MTQ5NzQ6MDow',
|
||||
additionalFields: {},
|
||||
},
|
||||
id: '0262484e-23cc-49b3-be29-8f205e49077a',
|
||||
name: 'Get many tasks in Google Tasks',
|
||||
type: 'n8n-nodes-base.googleTasksTool',
|
||||
position: [2528, 848],
|
||||
typeVersion: 1,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content: '## [Video Tutorial](https://youtu.be/ROgf5dVqYPQ)\n@[youtube](ROgf5dVqYPQ)',
|
||||
height: 400,
|
||||
width: 544,
|
||||
color: 7,
|
||||
},
|
||||
id: 'ef46cbde-6e82-4488-b027-d70087f1b5f4',
|
||||
name: 'Sticky Note2',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
position: [2944, 256],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
resource: 'audio',
|
||||
operation: 'transcribe',
|
||||
options: {},
|
||||
},
|
||||
id: '3acacedb-bafa-4fbf-8d3e-f198b19b9308',
|
||||
name: 'Transcribe a recording',
|
||||
type: '@n8n/n8n-nodes-langchain.openAi',
|
||||
position: [1872, 400],
|
||||
typeVersion: 1.8,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
id: '2ea5b79c-2e49-4d7e-933c-8b1a58d2415c',
|
||||
name: 'Send Email',
|
||||
type: 'n8n-nodes-base.gmailTool',
|
||||
position: [2944, 832],
|
||||
webhookId: 'a4ae7b5d-7686-4bee-a753-848932860b4e',
|
||||
typeVersion: 2.1,
|
||||
credentials: {},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
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",
|
||||
},
|
||||
},
|
||||
id: '4ec85126-51da-4f3b-a04f-16552fdcb244',
|
||||
name: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [2224, 464],
|
||||
typeVersion: 1.6,
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Google Calendar': {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'ai_tool',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Window Buffer Memory': {
|
||||
ai_memory: [
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'ai_memory',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Get Email': {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'ai_tool',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Listen for incoming events': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Voice or Text',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
If: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Get Voice File',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Voice or Text': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'If',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Get Voice File': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Transcribe a recording',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
OpenRouter: {
|
||||
ai_languageModel: [
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'ai_languageModel',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Create a task in Google Tasks': {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'ai_tool',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Get many tasks in Google Tasks': {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'ai_tool',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Transcribe a recording': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Send Email': {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
node: 'Jackie, AI Assistant 👩🏻🏫',
|
||||
type: 'ai_tool',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Jackie, AI Assistant 👩🏻🏫': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Telegram',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
109
packages/@n8n/ai-workflow-builder.ee/src/tools/web/templates.ts
Normal file
109
packages/@n8n/ai-workflow-builder.ee/src/tools/web/templates.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type {
|
||||
TemplateSearchQuery,
|
||||
TemplateSearchResponse,
|
||||
Category,
|
||||
TemplateFetchResponse,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* Base URL for n8n template API
|
||||
*/
|
||||
const N8N_API_BASE_URL = 'https://api.n8n.io/api';
|
||||
|
||||
/**
|
||||
* Type guard for TemplateSearchResponse
|
||||
*/
|
||||
function isTemplateSearchResponse(data: unknown): data is TemplateSearchResponse {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
const obj = data as Record<string, unknown>;
|
||||
return typeof obj.totalWorkflows === 'number' && Array.isArray(obj.workflows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for TemplateFetchResponse
|
||||
*/
|
||||
function isTemplateFetchResponse(data: unknown): data is TemplateFetchResponse {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.id === 'number' &&
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.workflow === 'object' &&
|
||||
obj.workflow !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from search parameters
|
||||
*/
|
||||
function buildSearchQueryString(query: TemplateSearchQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Fixed preset values (not overridable)
|
||||
params.append('price', '0'); // Always free templates
|
||||
params.append('combineWith', 'and'); // Don't ignore any search criteria
|
||||
params.append('sort', 'createdAt:desc,rank:desc'); // Most recent templates first
|
||||
params.append('rows', String(query.rows ?? 5)); // Default 5 results per page
|
||||
params.append('page', '1'); // Always first page
|
||||
|
||||
// Optional user-provided values
|
||||
if (query.search) params.append('search', query.search);
|
||||
if (query.category) params.append('category', query.category);
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch template/workflow list from n8n API
|
||||
*/
|
||||
export async function fetchTemplateList(query: {
|
||||
search?: string;
|
||||
category?: Category;
|
||||
rows?: number;
|
||||
}): Promise<TemplateSearchResponse> {
|
||||
const queryString = buildSearchQueryString(query);
|
||||
const url = `${N8N_API_BASE_URL}/templates/search${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch templates: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
if (!isTemplateSearchResponse(data)) {
|
||||
throw new Error('Invalid response format from templates API');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific workflow template by ID from n8n API
|
||||
*/
|
||||
export async function fetchTemplateByID(id: number): Promise<TemplateFetchResponse> {
|
||||
const url = `${N8N_API_BASE_URL}/workflows/templates/${id}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch template ${id}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
if (!isTemplateFetchResponse(data)) {
|
||||
throw new Error(`Invalid response format from template ${id} API`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
import { shouldRunIntegrationTests } from '@/chains/test/integration/test-helpers';
|
||||
import type { Category } from '@/types';
|
||||
|
||||
import { fetchTemplateList, fetchTemplateByID } from '../../templates';
|
||||
|
||||
/**
|
||||
* Integration tests for templates API
|
||||
*
|
||||
* These tests make actual API calls to n8n's template API.
|
||||
* They are skipped by default and only run when ENABLE_INTEGRATION_TESTS=true
|
||||
*
|
||||
* To run these tests:
|
||||
* ENABLE_INTEGRATION_TESTS=true pnpm test templates.integration
|
||||
*/
|
||||
|
||||
describe('Templates API - Integration Tests', () => {
|
||||
const skipTests = !shouldRunIntegrationTests();
|
||||
|
||||
// Set default timeout for all tests in this suite
|
||||
jest.setTimeout(30000); // 30 seconds for API calls
|
||||
|
||||
beforeAll(() => {
|
||||
if (skipTests) {
|
||||
console.log(
|
||||
'\n⏭️ Skipping integration tests. Set ENABLE_INTEGRATION_TESTS=true to run them.\n',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe('fetchTemplateList', () => {
|
||||
it('should fetch templates with search query', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const result = await fetchTemplateList({ search: 'slack' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workflows).toBeDefined();
|
||||
expect(Array.isArray(result.workflows)).toBe(true);
|
||||
expect(result.totalWorkflows).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// If there are results, check structure
|
||||
if (result.workflows.length > 0) {
|
||||
const workflow = result.workflows[0];
|
||||
expect(workflow.id).toBeDefined();
|
||||
expect(workflow.name).toBeDefined();
|
||||
expect(typeof workflow.name).toBe('string');
|
||||
expect(workflow.nodes).toBeDefined();
|
||||
expect(Array.isArray(workflow.nodes)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fetch templates with category filter', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const result = await fetchTemplateList({ category: 'Marketing' as Category });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workflows).toBeDefined();
|
||||
expect(Array.isArray(result.workflows)).toBe(true);
|
||||
expect(result.totalWorkflows).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// If there are results, check structure
|
||||
if (result.workflows.length > 0) {
|
||||
const workflow = result.workflows[0];
|
||||
expect(workflow.id).toBeDefined();
|
||||
expect(workflow.name).toBeDefined();
|
||||
expect(workflow.nodes).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should fetch templates without any filters', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const result = await fetchTemplateList({});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workflows).toBeDefined();
|
||||
expect(Array.isArray(result.workflows)).toBe(true);
|
||||
expect(result.totalWorkflows).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Should return some results when no filter is applied
|
||||
expect(result.workflows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle search with no results gracefully', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const result = await fetchTemplateList({
|
||||
search: 'xyznonexistentworkflow123456789',
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workflows).toBeDefined();
|
||||
expect(Array.isArray(result.workflows)).toBe(true);
|
||||
expect(result.workflows.length).toBe(0);
|
||||
expect(result.totalWorkflows).toBe(0);
|
||||
});
|
||||
|
||||
it('should return results ordered by views (most popular first)', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const result = await fetchTemplateList({ search: 'email' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workflows).toBeDefined();
|
||||
|
||||
// If we have at least 2 results, check ordering
|
||||
if (result.workflows.length >= 2) {
|
||||
const firstViews = result.workflows[0].totalViews ?? 0;
|
||||
const secondViews = result.workflows[1].totalViews ?? 0;
|
||||
|
||||
// First result should have more or equal views than second
|
||||
expect(firstViews).toBeGreaterThanOrEqual(secondViews);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate response structure for all workflows', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const result = await fetchTemplateList({ search: 'webhook' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workflows).toBeDefined();
|
||||
|
||||
// Validate structure of each workflow
|
||||
for (const workflow of result.workflows) {
|
||||
expect(workflow.id).toBeDefined();
|
||||
expect(typeof workflow.id).toBe('number');
|
||||
expect(workflow.name).toBeDefined();
|
||||
expect(typeof workflow.name).toBe('string');
|
||||
expect(workflow.description).toBeDefined();
|
||||
expect(workflow.nodes).toBeDefined();
|
||||
expect(Array.isArray(workflow.nodes)).toBe(true);
|
||||
|
||||
// User info should be present
|
||||
expect(workflow.user).toBeDefined();
|
||||
expect(workflow.user.id).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTemplateByID', () => {
|
||||
let validTemplateId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
// Get a valid template ID from the list
|
||||
const result = await fetchTemplateList({});
|
||||
if (result.workflows.length > 0) {
|
||||
validTemplateId = result.workflows[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
it('should fetch a template by valid ID', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
expect(validTemplateId).toBeDefined();
|
||||
|
||||
const result = await fetchTemplateByID(validTemplateId);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(validTemplateId);
|
||||
expect(result.name).toBeDefined();
|
||||
expect(typeof result.name).toBe('string');
|
||||
expect(result.workflow).toBeDefined();
|
||||
expect(result.workflow.nodes).toBeDefined();
|
||||
expect(Array.isArray(result.workflow.nodes)).toBe(true);
|
||||
expect(result.workflow.connections).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include complete workflow data', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const result = await fetchTemplateByID(validTemplateId);
|
||||
|
||||
expect(result.workflow).toBeDefined();
|
||||
expect(result.workflow.nodes).toBeDefined();
|
||||
expect(result.workflow.nodes.length).toBeGreaterThan(0);
|
||||
|
||||
// Check first node structure
|
||||
const firstNode = result.workflow.nodes[0];
|
||||
expect(firstNode.id).toBeDefined();
|
||||
expect(firstNode.name).toBeDefined();
|
||||
expect(firstNode.type).toBeDefined();
|
||||
expect(firstNode.position).toBeDefined();
|
||||
expect(firstNode.parameters).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid template ID with error', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
// Use a very large ID that likely doesn't exist
|
||||
const invalidId = 999999999;
|
||||
|
||||
await expect(fetchTemplateByID(invalidId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should fetch multiple templates with different workflows', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
// Get multiple template IDs
|
||||
const listResult = await fetchTemplateList({});
|
||||
|
||||
const firstId = listResult.workflows[0].id;
|
||||
const secondId = listResult.workflows[1].id;
|
||||
|
||||
const firstTemplate = await fetchTemplateByID(firstId);
|
||||
const secondTemplate = await fetchTemplateByID(secondId);
|
||||
|
||||
expect(firstTemplate.id).toBe(firstId);
|
||||
expect(secondTemplate.id).toBe(secondId);
|
||||
expect(firstTemplate.id).not.toBe(secondTemplate.id);
|
||||
expect(firstTemplate.name).not.toBe(secondTemplate.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration between list and fetch', () => {
|
||||
it('should fetch detailed workflow for each search result', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
// Search for templates
|
||||
const listResult = await fetchTemplateList({ search: 'chatbot' });
|
||||
|
||||
// Take first 3 results (or less if fewer available)
|
||||
const templatesToFetch = listResult.workflows.slice(0, 3);
|
||||
|
||||
for (const template of templatesToFetch) {
|
||||
const detailedTemplate = await fetchTemplateByID(template.id);
|
||||
|
||||
// The basic info should match
|
||||
expect(detailedTemplate.id).toBe(template.id);
|
||||
expect(detailedTemplate.name).toBe(template.name);
|
||||
expect(detailedTemplate.name.toLowerCase()).toContain('chatbot');
|
||||
|
||||
// Detailed version should have complete workflow
|
||||
expect(detailedTemplate.workflow).toBeDefined();
|
||||
expect(detailedTemplate.workflow.nodes.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('API response validation', () => {
|
||||
it('should receive consistent data types across multiple calls', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
// Make multiple calls and verify consistency
|
||||
const queries = ['email', 'slack', 'database'];
|
||||
const results = [];
|
||||
|
||||
for (const query of queries) {
|
||||
const result = await fetchTemplateList({ search: query });
|
||||
results.push(result);
|
||||
|
||||
// Verify structure
|
||||
expect(typeof result.totalWorkflows).toBe('number');
|
||||
expect(Array.isArray(result.workflows)).toBe(true);
|
||||
|
||||
for (const workflow of result.workflows) {
|
||||
expect(typeof workflow.id).toBe('number');
|
||||
expect(typeof workflow.name).toBe('string');
|
||||
expect(Array.isArray(workflow.nodes)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,3 +10,6 @@ export type * from './config';
|
|||
export type * from './utils';
|
||||
export type * from './categorization';
|
||||
export type * from './best-practices';
|
||||
|
||||
// Re-export web/templates (includes both types and runtime values)
|
||||
export * from './web/templates';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { ZodIssue } from 'zod';
|
|||
|
||||
import type { PromptCategorization } from './categorization';
|
||||
import type { AddedNode, NodeDetails, NodeSearchResult } from './nodes';
|
||||
import type { SimpleWorkflow } from './workflow';
|
||||
|
||||
/**
|
||||
* Types of progress updates
|
||||
|
|
@ -152,3 +153,40 @@ export interface RemoveConnectionOutput {
|
|||
export interface CategorizePromptOutput {
|
||||
categorization: PromptCategorization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Description of a workflow example we have found
|
||||
*/
|
||||
export interface WorkflowMetadata {
|
||||
name: string;
|
||||
description?: string;
|
||||
workflow: SimpleWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* A node configuration entry with version information
|
||||
*/
|
||||
export interface NodeConfigurationEntry {
|
||||
version: number;
|
||||
parameters: INodeParameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of node type to array of parameter configurations with version info
|
||||
* Key: node type (e.g., 'n8n-nodes-base.telegram')
|
||||
* Value: array of configuration entries with version and parameters
|
||||
*/
|
||||
export type NodeConfigurationsMap = Record<string, NodeConfigurationEntry[]>;
|
||||
|
||||
/**
|
||||
* Output type for get workflow examples tool
|
||||
*/
|
||||
export interface GetWorkflowExamplesOutput {
|
||||
examples: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
workflow: string;
|
||||
}>;
|
||||
totalResults: number;
|
||||
nodeConfigurations: NodeConfigurationsMap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import type { SimpleWorkflow } from '../workflow';
|
||||
|
||||
// retrieved from https://api.n8n.io/api/templates/categories
|
||||
export const categories = [
|
||||
'AI',
|
||||
'AI Chatbot',
|
||||
'AI RAG',
|
||||
'AI Summarization',
|
||||
'Content Creation',
|
||||
'CRM',
|
||||
'Crypto Trading',
|
||||
'DevOps',
|
||||
'Document Extraction',
|
||||
'Document Ops',
|
||||
'Engineering',
|
||||
'File Management',
|
||||
'HR',
|
||||
'Internal Wiki',
|
||||
'Invoice Processing',
|
||||
'IT Ops',
|
||||
'Lead Generation',
|
||||
'Lead Nurturing',
|
||||
'Marketing',
|
||||
'Market Research',
|
||||
'Miscellaneous',
|
||||
'Multimodal AI',
|
||||
'Other',
|
||||
'Personal Productivity',
|
||||
'Project Management',
|
||||
'Sales',
|
||||
'SecOps',
|
||||
'Social Media',
|
||||
'Support',
|
||||
'Support Chatbot',
|
||||
'Ticket Management',
|
||||
] as const;
|
||||
|
||||
export type Category = (typeof categories)[number];
|
||||
|
||||
/**
|
||||
* Query parameters for workflow examples search
|
||||
*/
|
||||
export interface TemplateSearchQuery {
|
||||
search?: string;
|
||||
rows?: number;
|
||||
page?: number;
|
||||
// sort is structured like column:desc/asc
|
||||
// examples: createdAt:desc|asc, _text_match:desc|asc, rank:desc|asc, trendingScore:desc|asc
|
||||
sort?: string;
|
||||
// 0 represents free
|
||||
price?: number;
|
||||
// how to combine these filters together
|
||||
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
|
||||
// hard to feed to the agent for use in search (free search will work better)
|
||||
}
|
||||
|
||||
// describes a workflow that can be retrieved, there are many more properties such as
|
||||
// icons, created at dates and user information - but these would not be useful to the builder
|
||||
export interface TemplateWorkflowDescription {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
totalViews: number;
|
||||
nodes: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
nodeCategories: Array<{ id: number; name: string }>;
|
||||
}>;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
verified: boolean;
|
||||
bio: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TemplateSearchResponse {
|
||||
totalWorkflows: number;
|
||||
workflows: TemplateWorkflowDescription[];
|
||||
// there is also a filters field which lists what was matched, but this isn't
|
||||
// useful to the workflow builder
|
||||
}
|
||||
|
||||
export interface TemplateFetchResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
workflow: SimpleWorkflow;
|
||||
}
|
||||
|
|
@ -596,6 +596,8 @@ describe('operations-processor', () => {
|
|||
validationHistory: [],
|
||||
techniqueCategories: [],
|
||||
previousSummary: 'EMPTY',
|
||||
nodeConfigurations: {},
|
||||
templateIds: [],
|
||||
});
|
||||
|
||||
it('should process operations and clear them', () => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ describe('tool-executor', () => {
|
|||
validationHistory: [],
|
||||
techniqueCategories: [],
|
||||
previousSummary: 'EMPTY',
|
||||
nodeConfigurations: {},
|
||||
templateIds: [],
|
||||
});
|
||||
|
||||
// Helper to create mock tool
|
||||
|
|
@ -816,5 +818,62 @@ describe('tool-executor', () => {
|
|||
expect(result.techniqueCategories).toHaveLength(4);
|
||||
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' } }],
|
||||
};
|
||||
|
||||
const command1 = new MockCommand({
|
||||
update: {
|
||||
messages: [new ToolMessage({ content: 'Examples', tool_call_id: 'call-1' })],
|
||||
nodeConfigurations: configs1,
|
||||
},
|
||||
});
|
||||
|
||||
const command2 = new MockCommand({
|
||||
update: {
|
||||
messages: [new ToolMessage({ content: 'More Examples', tool_call_id: 'call-2' })],
|
||||
nodeConfigurations: configs2,
|
||||
},
|
||||
});
|
||||
|
||||
const mockTool1 = createMockTool(command1);
|
||||
const mockTool2 = createMockTool(command2);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'examples_tool_1',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'examples_tool_2',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([
|
||||
['examples_tool_1', mockTool1],
|
||||
['examples_tool_2', mockTool2],
|
||||
]);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,9 +5,82 @@ import { isCommand } from '@langchain/langgraph';
|
|||
|
||||
import { ToolExecutionError, WorkflowStateError } from '../errors';
|
||||
import type { ToolExecutorOptions } from '../types/config';
|
||||
import type { NodeConfigurationsMap } from '../types/tools';
|
||||
import type { WorkflowOperation } from '../types/workflow';
|
||||
import type { WorkflowState } from '../workflow-state';
|
||||
|
||||
type StateUpdate = Partial<typeof WorkflowState.State>;
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is an array
|
||||
*/
|
||||
function isArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and flatten arrays from state updates for a given key.
|
||||
* Uses type guard for array detection and explicit typing on the result.
|
||||
* @param updates - State updates to collect from
|
||||
* @param key - The key to collect array values from
|
||||
* @returns Flattened array of values from the specified key
|
||||
*/
|
||||
function collectArrayFromUpdates<T>(updates: StateUpdate[], key: keyof StateUpdate): T[] {
|
||||
const result: T[] = [];
|
||||
for (const update of updates) {
|
||||
const value = update[key];
|
||||
if (isArray(value)) {
|
||||
// Each element is validated as part of the source StateUpdate structure
|
||||
for (const item of value) {
|
||||
result.push(item as T);
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
*/
|
||||
function createToolErrorMessage(toolName: string, toolCallId: string, error: unknown): ToolMessage {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
const isParsingError =
|
||||
error instanceof ToolInputParsingException || errorMessage.includes('expected schema');
|
||||
|
||||
const errorContent = isParsingError
|
||||
? `Invalid input for tool ${toolName}: ${errorMessage}`
|
||||
: `Tool ${toolName} failed: ${errorMessage}`;
|
||||
|
||||
return new ToolMessage({
|
||||
content: errorContent,
|
||||
tool_call_id: toolCallId,
|
||||
name: toolName,
|
||||
additional_kwargs: { error: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PARALLEL TOOL EXECUTION
|
||||
*
|
||||
|
|
@ -71,27 +144,7 @@ export async function executeToolsInParallel(
|
|||
} catch (error) {
|
||||
// Handle tool invocation errors by returning a ToolMessage with error
|
||||
// This ensures the conversation history remains valid (every tool_use has a tool_result)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
// Create error message content
|
||||
let errorContent: string;
|
||||
if (
|
||||
error instanceof ToolInputParsingException ||
|
||||
errorMessage.includes('expected schema')
|
||||
) {
|
||||
errorContent = `Invalid input for tool ${toolCall.name}: ${errorMessage}`;
|
||||
} else {
|
||||
errorContent = `Tool ${toolCall.name} failed: ${errorMessage}`;
|
||||
}
|
||||
|
||||
// Return a ToolMessage with the error to maintain conversation continuity
|
||||
return new ToolMessage({
|
||||
content: errorContent,
|
||||
tool_call_id: toolCall.id ?? '',
|
||||
name: toolCall.name,
|
||||
// Include error flag so tools can handle errors appropriately
|
||||
additional_kwargs: { error: true },
|
||||
});
|
||||
return createToolErrorMessage(toolCall.name, toolCall.id ?? '', error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -113,39 +166,27 @@ export async function executeToolsInParallel(
|
|||
}
|
||||
});
|
||||
|
||||
// Collect all messages from state updates
|
||||
stateUpdates.forEach((update) => {
|
||||
if (update.messages && Array.isArray(update.messages)) {
|
||||
allMessages.push(...update.messages);
|
||||
}
|
||||
});
|
||||
// Collect messages from state updates
|
||||
allMessages.push(...collectArrayFromUpdates<BaseMessage>(stateUpdates, 'messages'));
|
||||
|
||||
// Collect all workflow operations
|
||||
const allOperations: WorkflowOperation[] = [];
|
||||
// Collect all state update arrays using helper function
|
||||
const allOperations = collectArrayFromUpdates<WorkflowOperation>(
|
||||
stateUpdates,
|
||||
'workflowOperations',
|
||||
);
|
||||
const allTechniqueCategories = collectArrayFromUpdates<string>(
|
||||
stateUpdates,
|
||||
'techniqueCategories',
|
||||
);
|
||||
const allValidationHistory = collectArrayFromUpdates<
|
||||
(typeof WorkflowState.State.validationHistory)[number]
|
||||
>(stateUpdates, 'validationHistory');
|
||||
|
||||
for (const update of stateUpdates) {
|
||||
if (update.workflowOperations && Array.isArray(update.workflowOperations)) {
|
||||
allOperations.push(...update.workflowOperations);
|
||||
}
|
||||
}
|
||||
// Merge node configurations from all updates
|
||||
const allNodeConfigurations = mergeNodeConfigurations(stateUpdates);
|
||||
|
||||
// Collect all technique categories
|
||||
const allTechniqueCategories: string[] = [];
|
||||
|
||||
for (const update of stateUpdates) {
|
||||
if (update.techniqueCategories && Array.isArray(update.techniqueCategories)) {
|
||||
allTechniqueCategories.push(...update.techniqueCategories);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all validation history
|
||||
const allValidationHistory: Array<(typeof WorkflowState.State.validationHistory)[number]> = [];
|
||||
|
||||
for (const update of stateUpdates) {
|
||||
if (update.validationHistory && Array.isArray(update.validationHistory)) {
|
||||
allValidationHistory.push(...update.validationHistory);
|
||||
}
|
||||
}
|
||||
// Collect template IDs from all updates
|
||||
const allTemplateIds = collectArrayFromUpdates<number>(stateUpdates, 'templateIds');
|
||||
|
||||
// Return the combined update
|
||||
const finalUpdate: Partial<typeof WorkflowState.State> = {
|
||||
|
|
@ -164,5 +205,13 @@ export async function executeToolsInParallel(
|
|||
finalUpdate.validationHistory = allValidationHistory;
|
||||
}
|
||||
|
||||
if (Object.keys(allNodeConfigurations).length > 0) {
|
||||
finalUpdate.nodeConfigurations = allNodeConfigurations;
|
||||
}
|
||||
|
||||
if (allTemplateIds.length > 0) {
|
||||
finalUpdate.templateIds = allTemplateIds;
|
||||
}
|
||||
|
||||
return finalUpdate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { LLMServiceError, ValidationError, WorkflowStateError } from './errors';
|
|||
import { createMultiAgentWorkflowWithSubgraphs } from './multi-agent-workflow-subgraphs';
|
||||
import { SessionManagerService } from './session-manager.service';
|
||||
import { getBuilderTools } from './tools/builder-tools';
|
||||
import { mainAgentPrompt } from './tools/prompts/main-agent.prompt';
|
||||
import { createMainAgentPrompt } from './tools/prompts/main-agent.prompt';
|
||||
import type { SimpleWorkflow } from './types/workflow';
|
||||
import {
|
||||
applyCacheControlMarkers,
|
||||
|
|
@ -151,6 +151,10 @@ export interface ExpressionValue {
|
|||
nodeType?: string;
|
||||
}
|
||||
|
||||
export interface BuilderFeatureFlags {
|
||||
templateExamples?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatPayload {
|
||||
message: string;
|
||||
workflowContext?: {
|
||||
|
|
@ -159,6 +163,7 @@ export interface ChatPayload {
|
|||
executionData?: IRunExecutionData['resultData'];
|
||||
expressionValues?: Record<string, ExpressionValue[]>;
|
||||
};
|
||||
featureFlags?: BuilderFeatureFlags;
|
||||
}
|
||||
|
||||
export class WorkflowBuilderAgent {
|
||||
|
|
@ -187,12 +192,13 @@ export class WorkflowBuilderAgent {
|
|||
this.enableMultiAgent = config.enableMultiAgent ?? false;
|
||||
}
|
||||
|
||||
private getBuilderTools(): BuilderTool[] {
|
||||
private getBuilderTools(featureFlags?: BuilderFeatureFlags): BuilderTool[] {
|
||||
return getBuilderTools({
|
||||
parsedNodeTypes: this.parsedNodeTypes,
|
||||
instanceUrl: this.instanceUrl,
|
||||
llmComplexTask: this.llmComplexTask,
|
||||
logger: this.logger,
|
||||
featureFlags,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -214,8 +220,8 @@ export class WorkflowBuilderAgent {
|
|||
/**
|
||||
* Create the legacy single-agent workflow graph
|
||||
*/
|
||||
private createLegacyWorkflow() {
|
||||
const builderTools = this.getBuilderTools();
|
||||
private createLegacyWorkflow(featureFlags?: BuilderFeatureFlags) {
|
||||
const builderTools = this.getBuilderTools(featureFlags);
|
||||
|
||||
// Extract just the tools for LLM binding
|
||||
const tools = builderTools.map((bt) => bt.tool);
|
||||
|
|
@ -223,6 +229,11 @@ export class WorkflowBuilderAgent {
|
|||
// Create a map for quick tool lookup
|
||||
const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
|
||||
// Create the prompt with feature flag options
|
||||
const mainAgentPrompt = createMainAgentPrompt({
|
||||
includeExamplesPhase: featureFlags?.templateExamples === true,
|
||||
});
|
||||
|
||||
const callModel = async (state: typeof WorkflowState.State) => {
|
||||
if (!this.llmSimpleTask) {
|
||||
throw new LLMServiceError('LLM not setup');
|
||||
|
|
@ -422,14 +433,14 @@ export class WorkflowBuilderAgent {
|
|||
/**
|
||||
* Create the workflow graph based on configuration
|
||||
*/
|
||||
private createWorkflow() {
|
||||
private createWorkflow(featureFlags?: BuilderFeatureFlags) {
|
||||
if (this.enableMultiAgent) {
|
||||
this.logger?.debug('Using multi-agent supervisor architecture');
|
||||
return this.createMultiAgentGraph();
|
||||
}
|
||||
|
||||
this.logger?.debug('Using legacy single-agent architecture');
|
||||
return this.createLegacyWorkflow();
|
||||
return this.createLegacyWorkflow(featureFlags);
|
||||
}
|
||||
|
||||
async getState(workflowId?: string, userId?: string): Promise<TypedStateSnapshot> {
|
||||
|
|
@ -480,7 +491,7 @@ export class WorkflowBuilderAgent {
|
|||
}
|
||||
|
||||
private setupAgentAndConfigs(payload: ChatPayload, userId?: string, abortSignal?: AbortSignal) {
|
||||
const agent = this.createWorkflow();
|
||||
const agent = this.createWorkflow(payload.featureFlags);
|
||||
const workflowId = payload.workflowContext?.currentWorkflow?.id;
|
||||
// Generate thread ID from workflowId and userId
|
||||
// This ensures one session per workflow per user
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { BaseMessage } from '@langchain/core/messages';
|
|||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { Annotation, messagesStateReducer } from '@langchain/langgraph';
|
||||
|
||||
import type { SimpleWorkflow, WorkflowOperation } from './types';
|
||||
import type { NodeConfigurationsMap, SimpleWorkflow, WorkflowOperation } from './types';
|
||||
import type { ProgrammaticEvaluationResult, TelemetryValidationStatus } from './validation/types';
|
||||
import type { ChatPayload } from './workflow-builder-agent';
|
||||
|
||||
|
|
@ -101,4 +101,30 @@ export const WorkflowState = Annotation.Root({
|
|||
reducer: (x, y) => y ?? x, // Overwrite with the latest summary
|
||||
default: () => 'EMPTY',
|
||||
}),
|
||||
|
||||
// Node configurations collected from workflow examples
|
||||
// Used to provide context when updating node parameters
|
||||
nodeConfigurations: Annotation<NodeConfigurationsMap>({
|
||||
reducer: (current, update) => {
|
||||
if (!update || Object.keys(update).length === 0) {
|
||||
return current;
|
||||
}
|
||||
// Merge configurations by node type, appending new configs to existing ones
|
||||
const merged = { ...current };
|
||||
for (const [nodeType, configs] of Object.entries(update)) {
|
||||
if (!merged[nodeType]) {
|
||||
merged[nodeType] = [];
|
||||
}
|
||||
merged[nodeType] = [...merged[nodeType], ...configs];
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
default: () => ({}),
|
||||
}),
|
||||
|
||||
// Template IDs fetched from workflow examples for telemetry
|
||||
templateIds: Annotation<number[]>({
|
||||
reducer: (current, update) => (update && update.length > 0 ? [...current, ...update] : current),
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,5 +57,10 @@ export class AiBuilderChatRequestDto extends Z.class({
|
|||
})
|
||||
.optional(),
|
||||
}),
|
||||
featureFlags: z
|
||||
.object({
|
||||
templateExamples: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export class AiController {
|
|||
|
||||
res.on('close', handleClose);
|
||||
|
||||
const { text, workflowContext } = payload.payload;
|
||||
const { text, workflowContext, featureFlags } = payload.payload;
|
||||
const aiResponse = this.workflowBuilderService.chat(
|
||||
{
|
||||
message: text,
|
||||
|
|
@ -65,6 +65,7 @@ export class AiController {
|
|||
executionSchema: workflowContext.executionSchema,
|
||||
expressionValues: workflowContext.expressionValues,
|
||||
},
|
||||
featureFlags,
|
||||
},
|
||||
req.user,
|
||||
signal,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ export const TEMPLATE_SETUP_EXPERIENCE = {
|
|||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const AI_BUILDER_TEMPLATE_EXAMPLES_EXPERIMENT = {
|
||||
name: '056_ai_builder_template_examples',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [
|
||||
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||
TEMPLATE_ONBOARDING_EXPERIMENT.name,
|
||||
|
|
@ -102,6 +108,7 @@ export const EXPERIMENTS_TO_TRACK = [
|
|||
TEMPLATE_RECO_V2.name,
|
||||
TEMPLATES_DATA_QUALITY_EXPERIMENT.name,
|
||||
READY_TO_RUN_V2_PART2_EXPERIMENT.name,
|
||||
AI_BUILDER_TEMPLATE_EXAMPLES_EXPERIMENT.name,
|
||||
TIME_SAVED_NODE_EXPERIMENT.name,
|
||||
TEMPLATE_SETUP_EXPERIENCE.name,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ export namespace ChatRequest {
|
|||
error?: ErrorContext['error'];
|
||||
}
|
||||
|
||||
export interface BuilderFeatureFlags {
|
||||
templateExamples?: boolean;
|
||||
}
|
||||
|
||||
export interface UserChatMessage {
|
||||
role: 'user';
|
||||
type: 'message';
|
||||
|
|
@ -100,6 +104,7 @@ export namespace ChatRequest {
|
|||
quickReplyType?: string;
|
||||
context?: UserContext;
|
||||
workflowContext?: WorkflowContext;
|
||||
featureFlags?: BuilderFeatureFlags;
|
||||
}
|
||||
|
||||
export interface UserContext {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { ChatRequest } from '@/features/ai/assistant/assistant.types';
|
||||
import { useAIAssistantHelpers } from '@/features/ai/assistant/composables/useAIAssistantHelpers';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { AI_BUILDER_TEMPLATE_EXAMPLES_EXPERIMENT } from '@/app/constants/experiments';
|
||||
import type { IRunExecutionData } from 'n8n-workflow';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
|
||||
|
|
@ -17,6 +19,7 @@ export function createBuilderPayload(
|
|||
} = {},
|
||||
): ChatRequest.UserChatMessage {
|
||||
const assistantHelpers = useAIAssistantHelpers();
|
||||
const posthogStore = usePostHog();
|
||||
const workflowContext: ChatRequest.WorkflowContext = {};
|
||||
|
||||
if (options.workflow) {
|
||||
|
|
@ -48,12 +51,20 @@ export function createBuilderPayload(
|
|||
);
|
||||
}
|
||||
|
||||
// Get feature flags from Posthog
|
||||
const featureFlags: ChatRequest.BuilderFeatureFlags = {
|
||||
templateExamples:
|
||||
posthogStore.getVariant(AI_BUILDER_TEMPLATE_EXAMPLES_EXPERIMENT.name) ===
|
||||
AI_BUILDER_TEMPLATE_EXAMPLES_EXPERIMENT.variant,
|
||||
};
|
||||
|
||||
return {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text,
|
||||
quickReplyType: options.quickReplyType,
|
||||
workflowContext,
|
||||
featureFlags,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user