mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
refactor(core): Consolidate the node search engine into @n8n/ai-utilities (no-changelog) (#35546)
This commit is contained in:
parent
fb430aab05
commit
b9496b2fa6
|
|
@ -1,6 +1,15 @@
|
|||
import { NodeSearchEngine, SCORE_WEIGHTS } from '../node-search-engine';
|
||||
import type { SearchableNodeType } from '../node-search-engine.types';
|
||||
import { AI_CONNECTION_TYPES } from '../node-search-engine.types';
|
||||
/**
|
||||
* Engine behaviour over a host-supplied `SearchableNodeType` rather than a full
|
||||
* `INodeTypeDescription`: result caching, n8n Connect metadata, and builder
|
||||
* hints. Moved here from @n8n/instance-ai when its forked search engine was
|
||||
* consolidated into this package.
|
||||
*/
|
||||
|
||||
import type { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { NodeSearchEngine, SCORE_WEIGHTS } from '../search-engine';
|
||||
import type { SearchableNodeType } from '../types';
|
||||
import { AI_CONNECTION_TYPES } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
|
|
@ -37,26 +46,34 @@ const slackNode = makeNode({
|
|||
description: 'Send messages to Slack',
|
||||
});
|
||||
|
||||
/**
|
||||
* A host's `builderHint` carries fields the engine does not declare, such as
|
||||
* `extraTypeDefContent`. Built outside the `makeNode` literal so it reaches the
|
||||
* engine the same way a real host's wider object does, rather than tripping the
|
||||
* excess-property check that only applies to fresh literals.
|
||||
*/
|
||||
const agentBuilderHint = {
|
||||
searchHint: 'Use an AI Agent for autonomous task execution',
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_memory: { required: false },
|
||||
ai_tool: { required: false, displayOptions: { show: { hasTools: [true] } } },
|
||||
},
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
content:
|
||||
'<patterns>\n<pattern title="basic">\nconst agent = node({ ... })\n</pattern>\n</patterns>',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const agentNode = makeNode({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
description: 'An AI agent that uses tools',
|
||||
inputs: ['main', 'ai_languageModel', 'ai_memory', 'ai_tool'],
|
||||
outputs: ['main'],
|
||||
builderHint: {
|
||||
message: 'Use an AI Agent for autonomous task execution',
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_memory: { required: false },
|
||||
ai_tool: { required: false, displayOptions: { show: { hasTools: [true] } } },
|
||||
},
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
content:
|
||||
'<patterns>\n<pattern title="basic">\nconst agent = node({ ... })\n</pattern>\n</patterns>',
|
||||
},
|
||||
],
|
||||
},
|
||||
builderHint: agentBuilderHint,
|
||||
});
|
||||
|
||||
const openAiNode = makeNode({
|
||||
|
|
@ -206,7 +223,7 @@ describe('NodeSearchEngine', () => {
|
|||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should include builder hint message when present', () => {
|
||||
it('should include the builder search hint alongside subnode requirements', () => {
|
||||
const results = engine.searchByName('AI Agent');
|
||||
const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent');
|
||||
expect(agentResult).toBeDefined();
|
||||
|
|
@ -319,7 +336,7 @@ describe('NodeSearchEngine', () => {
|
|||
});
|
||||
|
||||
it('should return empty for unknown connection type', () => {
|
||||
const results = engine.searchByConnectionType('ai_nonexistent');
|
||||
const results = engine.searchByConnectionType('ai_nonexistent' as NodeConnectionType);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -536,6 +553,40 @@ describe('NodeSearchEngine', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('host shapes carrying extra fields', () => {
|
||||
it('reads a host node that also carries a properties array, leaving it untouched', () => {
|
||||
// `properties` is the field a full INodeTypeDescription is recognised by,
|
||||
// and a host shape is free to carry one too. Its presence must not make the
|
||||
// engine reshape the node.
|
||||
const hostNodeWithProperties = {
|
||||
...makeNode({
|
||||
name: 'n8n-nodes-base.hostish',
|
||||
displayName: 'Hostish Node',
|
||||
description: 'A host shape that also exposes properties',
|
||||
outputs: ['ai_tool'],
|
||||
builderHint: { searchHint: 'host hint' },
|
||||
}),
|
||||
properties: [{ name: 'resource', type: 'options' }],
|
||||
};
|
||||
|
||||
const engine = new NodeSearchEngine([hostNodeWithProperties]);
|
||||
|
||||
// Same object back, so nothing converted or copied it on the way in.
|
||||
expect(engine.getNodeType('n8n-nodes-base.hostish')).toBe(hostNodeWithProperties);
|
||||
|
||||
const [byName] = engine.searchByName('Hostish');
|
||||
expect(byName.displayName).toBe('Hostish Node');
|
||||
expect(byName.builderHintMessage).toBe('host hint');
|
||||
|
||||
const [byConnection] = engine.searchByConnectionType('ai_tool' as NodeConnectionType);
|
||||
expect(byConnection.name).toBe('n8n-nodes-base.hostish');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Static methods
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -11,11 +11,11 @@ import {
|
|||
type NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { CodeBuilderNodeSearchEngine, SCORE_WEIGHTS } from '../search-engine';
|
||||
import { NodeSearchEngine, SCORE_WEIGHTS } from '../search-engine';
|
||||
import { createNodeType } from './helpers';
|
||||
|
||||
describe('CodeBuilderNodeSearchEngine', () => {
|
||||
let searchEngine: CodeBuilderNodeSearchEngine;
|
||||
describe('NodeSearchEngine', () => {
|
||||
let searchEngine: NodeSearchEngine;
|
||||
let nodeTypes: INodeTypeDescription[];
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -76,7 +76,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
}),
|
||||
];
|
||||
|
||||
searchEngine = new CodeBuilderNodeSearchEngine(nodeTypes);
|
||||
searchEngine = new NodeSearchEngine(nodeTypes);
|
||||
});
|
||||
|
||||
describe('searchByName', () => {
|
||||
|
|
@ -150,7 +150,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
inputs: [],
|
||||
outputs: ['ai_tool'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, httpToolNode]);
|
||||
const engine = new NodeSearchEngine([...nodeTypes, httpToolNode]);
|
||||
|
||||
const results = engine.searchByName('http', 1, (nodeId) => nodeId.endsWith('Tool'));
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
displayName: 'Test Node',
|
||||
description: undefined,
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeWithoutDesc]);
|
||||
const engine = new NodeSearchEngine([nodeWithoutDesc]);
|
||||
|
||||
const results = engine.searchByName('test');
|
||||
|
||||
|
|
@ -187,7 +187,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
displayName: undefined as unknown as string,
|
||||
} as INodeTypeDescription;
|
||||
|
||||
const engineWithMalformed = new CodeBuilderNodeSearchEngine([...nodeTypes, malformedNode]);
|
||||
const engineWithMalformed = new NodeSearchEngine([...nodeTypes, malformedNode]);
|
||||
|
||||
// Should not throw and should return valid results
|
||||
expect(() => engineWithMalformed.searchByName('http')).not.toThrow();
|
||||
|
|
@ -216,12 +216,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
description: 'Work with datasets',
|
||||
group: ['transform'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([
|
||||
...nodeTypes,
|
||||
setNode,
|
||||
settingsNode,
|
||||
datasetNode,
|
||||
]);
|
||||
const engine = new NodeSearchEngine([...nodeTypes, setNode, settingsNode, datasetNode]);
|
||||
|
||||
const results = engine.searchByName('set', 5);
|
||||
|
||||
|
|
@ -245,7 +240,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
group: ['transform'],
|
||||
}),
|
||||
);
|
||||
const engine = new CodeBuilderNodeSearchEngine([...competitors, setNode]);
|
||||
const engine = new NodeSearchEngine([...competitors, setNode]);
|
||||
|
||||
const results = engine.searchByName('set', 3);
|
||||
|
||||
|
|
@ -315,10 +310,10 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
}),
|
||||
];
|
||||
|
||||
let engine: CodeBuilderNodeSearchEngine;
|
||||
let engine: NodeSearchEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new CodeBuilderNodeSearchEngine(multiWordNodes);
|
||||
engine = new NodeSearchEngine(multiWordNodes);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -377,7 +372,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
displayName: 'Code Tool',
|
||||
outputs: ['ai_tool'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, anotherTool]);
|
||||
const engine = new NodeSearchEngine([...nodeTypes, anotherTool]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool);
|
||||
|
||||
|
|
@ -413,7 +408,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
displayName: 'Expression Node',
|
||||
outputs: '={{ $parameter.mode === "tool" ? "ai_tool" : "main" }}',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, expressionNode]);
|
||||
const engine = new NodeSearchEngine([...nodeTypes, expressionNode]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool);
|
||||
|
||||
|
|
@ -435,7 +430,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
displayName: 'Something Else',
|
||||
outputs: '={{ "ai_tool" }}',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, exactMatch, expressionMatch]);
|
||||
const engine = new NodeSearchEngine([...nodeTypes, exactMatch, expressionMatch]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool, 20, 'calculator');
|
||||
|
||||
|
|
@ -472,7 +467,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
outputs: ['ai_tool'],
|
||||
}),
|
||||
);
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, ...manyNodes]);
|
||||
const engine = new NodeSearchEngine([...nodeTypes, ...manyNodes]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool, 5);
|
||||
|
||||
|
|
@ -559,14 +554,14 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
|
||||
describe('static methods', () => {
|
||||
it('should identify AI connection types', () => {
|
||||
expect(CodeBuilderNodeSearchEngine.isAiConnectionType('ai_tool')).toBe(true);
|
||||
expect(CodeBuilderNodeSearchEngine.isAiConnectionType('ai_languageModel')).toBe(true);
|
||||
expect(CodeBuilderNodeSearchEngine.isAiConnectionType('main')).toBe(false);
|
||||
expect(CodeBuilderNodeSearchEngine.isAiConnectionType('Main')).toBe(false);
|
||||
expect(NodeSearchEngine.isAiConnectionType('ai_tool')).toBe(true);
|
||||
expect(NodeSearchEngine.isAiConnectionType('ai_languageModel')).toBe(true);
|
||||
expect(NodeSearchEngine.isAiConnectionType('main')).toBe(false);
|
||||
expect(NodeSearchEngine.isAiConnectionType('Main')).toBe(false);
|
||||
});
|
||||
|
||||
it('should get all AI connection types', () => {
|
||||
const aiTypes = CodeBuilderNodeSearchEngine.getAiConnectionTypes();
|
||||
const aiTypes = NodeSearchEngine.getAiConnectionTypes();
|
||||
|
||||
expect(aiTypes).toContain(NodeConnectionTypes.AiTool);
|
||||
expect(aiTypes).toContain(NodeConnectionTypes.AiLanguageModel);
|
||||
|
|
@ -582,7 +577,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle empty node types array', () => {
|
||||
const emptyEngine = new CodeBuilderNodeSearchEngine([]);
|
||||
const emptyEngine = new NodeSearchEngine([]);
|
||||
|
||||
expect(emptyEngine.searchByName('test')).toEqual([]);
|
||||
expect(emptyEngine.searchByConnectionType(NodeConnectionTypes.AiTool)).toEqual([]);
|
||||
|
|
@ -597,7 +592,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
alias: null as unknown as string[],
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeWithNulls]);
|
||||
const engine = new NodeSearchEngine([nodeWithNulls]);
|
||||
|
||||
expect(() => engine.searchByName('null')).not.toThrow();
|
||||
const results = engine.searchByName('null');
|
||||
|
|
@ -640,7 +635,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
version: 3,
|
||||
});
|
||||
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeV1, nodeV2, nodeV3]);
|
||||
const engine = new NodeSearchEngine([nodeV1, nodeV2, nodeV3]);
|
||||
const results = engine.searchByName('http');
|
||||
|
||||
// Should only return one node (the latest version)
|
||||
|
|
@ -661,7 +656,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
version: [2, 3],
|
||||
});
|
||||
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeV1, nodeV2V3]);
|
||||
const engine = new NodeSearchEngine([nodeV1, nodeV2V3]);
|
||||
const results = engine.searchByName('code');
|
||||
|
||||
// Should return the node with array version [2, 3] as latest
|
||||
|
|
@ -682,7 +677,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
version: 3,
|
||||
});
|
||||
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeV1V2, nodeV3]);
|
||||
const engine = new NodeSearchEngine([nodeV1V2, nodeV3]);
|
||||
const results = engine.searchByName('webhook');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
|
|
@ -738,7 +733,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
searchHint: 'Use with output parser for structured output',
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode]);
|
||||
const engine = new NodeSearchEngine([agentNode]);
|
||||
|
||||
const results = engine.searchByName('agent');
|
||||
|
||||
|
|
@ -784,7 +779,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
} as unknown as NonNullable<INodeTypeDescription['builderHint']>['inputs'],
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode]);
|
||||
const engine = new NodeSearchEngine([agentNode]);
|
||||
|
||||
const results = engine.searchByName('agent');
|
||||
|
||||
|
|
@ -799,7 +794,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([basicNode]);
|
||||
const engine = new NodeSearchEngine([basicNode]);
|
||||
|
||||
const results = engine.searchByName('http');
|
||||
|
||||
|
|
@ -818,7 +813,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([openAiModel]);
|
||||
const engine = new NodeSearchEngine([openAiModel]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiLanguageModel);
|
||||
|
||||
|
|
@ -843,7 +838,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
searchHint: 'Test hint message',
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode]);
|
||||
const engine = new NodeSearchEngine([agentNode]);
|
||||
|
||||
const results = engine.searchByName('agent');
|
||||
const formatted = engine.formatResult(results[0]);
|
||||
|
|
@ -867,7 +862,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([basicNode]);
|
||||
const engine = new NodeSearchEngine([basicNode]);
|
||||
|
||||
const results = engine.searchByName('code');
|
||||
const formatted = engine.formatResult(results[0]);
|
||||
|
|
@ -879,7 +874,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
|
||||
describe('getSubnodesForConnectionType', () => {
|
||||
it('should return default subnodes for ai_languageModel', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
const engine = new NodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('ai_languageModel');
|
||||
|
||||
|
|
@ -887,7 +882,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
});
|
||||
|
||||
it('should return default subnodes for ai_memory', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
const engine = new NodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('ai_memory');
|
||||
|
||||
|
|
@ -895,7 +890,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
});
|
||||
|
||||
it('should return default subnodes for ai_embedding', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
const engine = new NodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('ai_embedding');
|
||||
|
||||
|
|
@ -903,7 +898,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
});
|
||||
|
||||
it('should return empty array for ai_tool (varies by use case)', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
const engine = new NodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('ai_tool');
|
||||
|
||||
|
|
@ -911,7 +906,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
});
|
||||
|
||||
it('should return empty array for unknown connection type', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
const engine = new NodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('unknown_type');
|
||||
|
||||
|
|
@ -941,7 +936,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
displayName: 'Window Buffer Memory',
|
||||
outputs: ['ai_memory'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode, openAiModel, bufferMemory]);
|
||||
const engine = new NodeSearchEngine([agentNode, openAiModel, bufferMemory]);
|
||||
|
||||
const relatedIds = engine.getRelatedSubnodeIds(['@n8n/n8n-nodes-langchain.agent'], new Set());
|
||||
|
||||
|
|
@ -977,11 +972,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
outputs: ['ai_embedding'],
|
||||
});
|
||||
|
||||
const engine = new CodeBuilderNodeSearchEngine([
|
||||
vectorMemory,
|
||||
inMemoryVectorStore,
|
||||
openAiEmbeddings,
|
||||
]);
|
||||
const engine = new NodeSearchEngine([vectorMemory, inMemoryVectorStore, openAiEmbeddings]);
|
||||
|
||||
const relatedIds = engine.getRelatedSubnodeIds(
|
||||
['@n8n/n8n-nodes-langchain.memoryVectorStore'],
|
||||
|
|
@ -1009,7 +1000,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
displayName: 'OpenAI Chat Model',
|
||||
outputs: ['ai_languageModel'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode, openAiModel]);
|
||||
const engine = new NodeSearchEngine([agentNode, openAiModel]);
|
||||
|
||||
// Exclude OpenAI model from results
|
||||
const relatedIds = engine.getRelatedSubnodeIds(
|
||||
|
|
@ -1025,7 +1016,7 @@ describe('CodeBuilderNodeSearchEngine', () => {
|
|||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([basicNode]);
|
||||
const engine = new NodeSearchEngine([basicNode]);
|
||||
|
||||
const relatedIds = engine.getRelatedSubnodeIds(['n8n-nodes-base.httpRequest'], new Set());
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
export { NodeTypeParser } from './node-type-parser';
|
||||
export type { ParsedNodeType } from './node-type-parser';
|
||||
|
||||
export { CodeBuilderNodeSearchEngine, SCORE_WEIGHTS } from './search-engine';
|
||||
export { NodeSearchEngine, SCORE_WEIGHTS } from './search-engine';
|
||||
|
||||
export { searchCodeBuilderNodes, formatNodeResult } from './search';
|
||||
export type {
|
||||
|
|
@ -80,4 +80,10 @@ export type {
|
|||
RequiredNodeCredentialSlot,
|
||||
} from './dynamic-node-parameters';
|
||||
|
||||
export type { SubnodeRequirement, CodeBuilderNodeSearchResult } from './types';
|
||||
export { AI_CONNECTION_TYPES } from './types';
|
||||
export type {
|
||||
AiGatewayNodeMeta,
|
||||
NodeSearchResult,
|
||||
SearchableNodeType,
|
||||
SubnodeRequirement,
|
||||
} from './types';
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
type LeanNodeTypeDescription,
|
||||
type VersionDiscriminators,
|
||||
} from './lean-node-type';
|
||||
import { CodeBuilderNodeSearchEngine } from './search-engine';
|
||||
import { NodeSearchEngine } from './search-engine';
|
||||
|
||||
export interface ParsedNodeType {
|
||||
id: string;
|
||||
|
|
@ -34,11 +34,11 @@ export interface ParsedNodeType {
|
|||
export class NodeTypeParser {
|
||||
private nodeTypes: LeanNodeTypeDescription[];
|
||||
private nodeTypeIndex: Map<string, LeanNodeTypeDescription[]>;
|
||||
private searchEngine: CodeBuilderNodeSearchEngine;
|
||||
private searchEngine: NodeSearchEngine;
|
||||
|
||||
constructor(nodeTypes: INodeTypeDescription[]) {
|
||||
this.nodeTypes = nodeTypes.map(toLeanNodeType);
|
||||
this.searchEngine = new CodeBuilderNodeSearchEngine(this.nodeTypes);
|
||||
this.searchEngine = new NodeSearchEngine(this.nodeTypes);
|
||||
this.nodeTypeIndex = this.buildIndex();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,41 @@
|
|||
/**
|
||||
* Code Builder Node Search Engine
|
||||
* Node Search Engine
|
||||
*
|
||||
* This is a fork of tools/engines/node-search-engine.ts for the code-builder agent.
|
||||
* It includes additional features for subnode requirements, related subnodes, and
|
||||
* builder hints that are specific to the code-builder workflow generation approach.
|
||||
* Shared by the MCP server tool surface (via `NodeCatalogService`) and Instance
|
||||
* AI, which each carried a near-identical fork until they drifted in their
|
||||
* connection-type lists. A third, older fork still lives in
|
||||
* `ai-workflow-builder.ee` and is tracked separately.
|
||||
*
|
||||
* The original node-search-engine.ts is used by the multi-agent system and has
|
||||
* different requirements.
|
||||
* Callers hand over whatever node shape they already hold: a full
|
||||
* `INodeTypeDescription`, a pre-digested `LeanNodeTypeDescription`, or the
|
||||
* leaner `SearchableNodeType` an embedding host assembles. See
|
||||
* {@link SearchableNodeType}.
|
||||
*/
|
||||
|
||||
import { sublimeSearch } from '@n8n/utils/search/sublime-search';
|
||||
import type { BuilderHintInputs, INodeTypeDescription, NodeConnectionType } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { INodeTypeDescription, NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { toLeanNodeType, type LeanNodeTypeDescription } from './lean-node-type';
|
||||
import type { CodeBuilderNodeSearchResult, SubnodeRequirement } from './types';
|
||||
import type { LeanNodeTypeDescription } from './lean-node-type';
|
||||
import {
|
||||
AI_CONNECTION_TYPES,
|
||||
type AiConnectionType,
|
||||
type NodeSearchResult,
|
||||
type SearchableBuilderHintInputs,
|
||||
type SearchableNodeType,
|
||||
type SubnodeRequirement,
|
||||
} from './types';
|
||||
|
||||
/** Detect already-lean inputs by their marker field so we can skip re-conversion. */
|
||||
function isLeanNodeType(
|
||||
node: INodeTypeDescription | LeanNodeTypeDescription,
|
||||
): node is LeanNodeTypeDescription {
|
||||
return 'discriminatorsByVersion' in node;
|
||||
}
|
||||
|
||||
/** Type guard for NodeConnectionType */
|
||||
function isNodeConnectionType(value: string): value is NodeConnectionType {
|
||||
return Object.values(NodeConnectionTypes).includes(value as NodeConnectionType);
|
||||
}
|
||||
/**
|
||||
* Any node shape the engine accepts.
|
||||
*
|
||||
* All three are read directly, with no conversion step: the engine only reads
|
||||
* the fields in {@link SearchableNodeType}, which every one of them already
|
||||
* carries. It never reads a lean-only field such as `discriminatorsByVersion`,
|
||||
* so there is nothing to gain from narrowing an input to one specific shape
|
||||
* (and no way to do it reliably, since a host shape is free to carry extra
|
||||
* fields like `properties`).
|
||||
*/
|
||||
export type SearchEngineInput = INodeTypeDescription | LeanNodeTypeDescription | SearchableNodeType;
|
||||
|
||||
/**
|
||||
* Default subnodes for each connection type
|
||||
|
|
@ -95,7 +104,7 @@ function getLatestVersion(version: number | number[]): number {
|
|||
return Array.isArray(version) ? Math.max(...version) : version;
|
||||
}
|
||||
|
||||
function extractSubnodeRequirements(inputs?: BuilderHintInputs): SubnodeRequirement[] {
|
||||
function extractSubnodeRequirements(inputs?: SearchableBuilderHintInputs): SubnodeRequirement[] {
|
||||
if (!inputs) return [];
|
||||
|
||||
return Object.entries(inputs)
|
||||
|
|
@ -110,8 +119,26 @@ function extractSubnodeRequirements(inputs?: BuilderHintInputs): SubnodeRequirem
|
|||
}));
|
||||
}
|
||||
|
||||
function dedupeNodes(nodes: LeanNodeTypeDescription[]): LeanNodeTypeDescription[] {
|
||||
const dedupeCache: Record<string, LeanNodeTypeDescription> = {};
|
||||
function toNodeSearchResult(node: SearchableNodeType, score: number): NodeSearchResult {
|
||||
const subnodeRequirements = extractSubnodeRequirements(node.builderHint?.inputs);
|
||||
const builderHintMessage = node.builderHint?.searchHint;
|
||||
|
||||
return {
|
||||
name: node.name,
|
||||
displayName: node.displayName,
|
||||
description: node.description ?? 'No description available',
|
||||
version: getLatestVersion(node.version),
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
score,
|
||||
...(builderHintMessage && { builderHintMessage }),
|
||||
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
|
||||
...(node.aiGateway && { aiGateway: node.aiGateway }),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeNodes(nodes: SearchEngineInput[]): SearchableNodeType[] {
|
||||
const dedupeCache: Record<string, SearchableNodeType> = {};
|
||||
nodes.forEach((node) => {
|
||||
const cachedNodeType = dedupeCache[node.name];
|
||||
if (!cachedNodeType) {
|
||||
|
|
@ -130,20 +157,65 @@ function dedupeNodes(nodes: LeanNodeTypeDescription[]): LeanNodeTypeDescription[
|
|||
return Object.values(dedupeCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap on cached queries per engine instance. An engine can outlive a great many
|
||||
* queries (`NodeCatalogService` holds one for the life of the process) and the
|
||||
* keys are model-authored query strings, so an uncapped map would grow without
|
||||
* bound. Evicting in insertion order is enough: the loser is recomputed on its
|
||||
* next use, which costs one search.
|
||||
*/
|
||||
const MAX_CACHED_QUERIES = 256;
|
||||
|
||||
function setCapped(cache: Map<string, NodeSearchResult[]>, key: string, value: NodeSearchResult[]) {
|
||||
if (cache.size >= MAX_CACHED_QUERIES) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey !== undefined) cache.delete(oldestKey);
|
||||
}
|
||||
cache.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a cached result before handing it out, so that a caller adding fields to
|
||||
* what it got back cannot corrupt the cache for the next query. Callers do
|
||||
* exactly that: the Instance AI nodes tool attaches `discriminators` per result
|
||||
* and `suggestedNode` per requirement.
|
||||
*
|
||||
* The copy is deliberately one level deep per object: the result itself and its
|
||||
* requirement objects. Nested values (`inputs`, `outputs`, a requirement's
|
||||
* `displayOptions`) stay shared with the cached entry, because nothing on either
|
||||
* side writes to them, and deep-copying them would cost on every cache hit to
|
||||
* guard against a mutation no caller performs.
|
||||
*/
|
||||
function cloneSearchResult(result: NodeSearchResult): NodeSearchResult {
|
||||
return {
|
||||
...result,
|
||||
...(result.subnodeRequirements
|
||||
? {
|
||||
subnodeRequirements: result.subnodeRequirements.map((requirement) => ({
|
||||
...requirement,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSearchResults(results: NodeSearchResult[]): NodeSearchResult[] {
|
||||
return results.map(cloneSearchResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure business logic for searching nodes
|
||||
* Separated from tool infrastructure for better testability.
|
||||
*
|
||||
* Accepts either full `INodeTypeDescription[]` or already-converted
|
||||
* `LeanNodeTypeDescription[]` — production callers pass lean to avoid the
|
||||
* conversion cost, tests typically pass full descriptions and the engine
|
||||
* converts on the way in.
|
||||
*/
|
||||
export class CodeBuilderNodeSearchEngine {
|
||||
private readonly nodeTypes: LeanNodeTypeDescription[];
|
||||
constructor(nodeTypes: Array<INodeTypeDescription | LeanNodeTypeDescription>) {
|
||||
const lean = nodeTypes.map((n) => (isLeanNodeType(n) ? n : toLeanNodeType(n)));
|
||||
this.nodeTypes = dedupeNodes(lean);
|
||||
export class NodeSearchEngine {
|
||||
private readonly nodeTypes: SearchableNodeType[];
|
||||
|
||||
private readonly nameSearchCache = new Map<string, NodeSearchResult[]>();
|
||||
|
||||
private readonly connectionSearchCache = new Map<string, NodeSearchResult[]>();
|
||||
|
||||
constructor(nodeTypes: SearchEngineInput[]) {
|
||||
this.nodeTypes = dedupeNodes(nodeTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -152,31 +224,28 @@ export class CodeBuilderNodeSearchEngine {
|
|||
* description keyword matching and direct type-name / display-name matching
|
||||
* for nodes the fuzzy search missed.
|
||||
*
|
||||
* Ported from the Instance AI `NodeSearchEngine` so code-builder / MCP node
|
||||
* search resolves natural-language multi-word queries (e.g. "telegram send
|
||||
* message", "ai agent langchain") instead of returning no results — these
|
||||
* previously failed because sublimeSearch matched the whole query string as a
|
||||
* single ordered character subsequence against one field.
|
||||
* The per-term splitting exists because sublimeSearch matches the whole query
|
||||
* as a single ordered character subsequence against one field, which fails
|
||||
* natural-language queries like "telegram send message" or "ai agent
|
||||
* langchain".
|
||||
*/
|
||||
private fuzzySearchNodes(
|
||||
query: string,
|
||||
candidates: LeanNodeTypeDescription[],
|
||||
candidates: SearchableNodeType[],
|
||||
limit?: number,
|
||||
): Array<{ item: LeanNodeTypeDescription; score: number }> {
|
||||
): Array<{ item: SearchableNodeType; score: number }> {
|
||||
const queryLower = query.toLowerCase().trim();
|
||||
const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 1);
|
||||
const isMultiWord = queryTerms.length > 1;
|
||||
|
||||
type ScoredNode = { item: LeanNodeTypeDescription; score: number };
|
||||
type ScoredNode = { item: SearchableNodeType; score: number };
|
||||
let searchResults: ScoredNode[];
|
||||
|
||||
// For multi-word queries, search each term individually then merge.
|
||||
// sublimeSearch breaks down on multi-word queries because it tries to
|
||||
// fuzzy-match the entire string as one character sequence.
|
||||
if (isMultiWord) {
|
||||
const scoreMap = new Map<string, ScoredNode>();
|
||||
for (const term of queryTerms) {
|
||||
const termResults = sublimeSearch<LeanNodeTypeDescription>(
|
||||
const termResults = sublimeSearch<SearchableNodeType>(
|
||||
term,
|
||||
candidates,
|
||||
NODE_SEARCH_KEYS,
|
||||
|
|
@ -190,7 +259,7 @@ export class CodeBuilderNodeSearchEngine {
|
|||
}
|
||||
searchResults = [...scoreMap.values()];
|
||||
} else {
|
||||
searchResults = sublimeSearch<LeanNodeTypeDescription>(query, candidates, NODE_SEARCH_KEYS);
|
||||
searchResults = sublimeSearch<SearchableNodeType>(query, candidates, NODE_SEARCH_KEYS);
|
||||
}
|
||||
|
||||
const fuzzyResultNames = new Set(searchResults.map((r) => r.item.name));
|
||||
|
|
@ -264,34 +333,28 @@ export class CodeBuilderNodeSearchEngine {
|
|||
query: string,
|
||||
limit: number = 20,
|
||||
nodeFilter?: (nodeId: string) => boolean,
|
||||
): CodeBuilderNodeSearchResult[] {
|
||||
const nodeTypes = nodeFilter
|
||||
? this.nodeTypes.filter((node) => nodeFilter(node.name))
|
||||
: this.nodeTypes;
|
||||
): NodeSearchResult[] {
|
||||
// A predicate can't be part of a cache key, so filtered searches skip the
|
||||
// cache rather than risk serving another filter's results.
|
||||
if (nodeFilter) {
|
||||
return this.fuzzySearchNodes(
|
||||
query,
|
||||
this.nodeTypes.filter((node) => nodeFilter(node.name)),
|
||||
limit,
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map(({ item, score }) => toNodeSearchResult(item, score));
|
||||
}
|
||||
|
||||
return this.fuzzySearchNodes(query, nodeTypes, limit)
|
||||
const cacheKey = `${query}\0${limit}`;
|
||||
const cached = this.nameSearchCache.get(cacheKey);
|
||||
if (cached) return cloneSearchResults(cached);
|
||||
|
||||
const results = this.fuzzySearchNodes(query, this.nodeTypes, limit)
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
({
|
||||
item,
|
||||
score,
|
||||
}: { item: LeanNodeTypeDescription; score: number }): CodeBuilderNodeSearchResult => {
|
||||
const subnodeRequirements = extractSubnodeRequirements(item.builderHint?.inputs);
|
||||
return {
|
||||
name: item.name,
|
||||
displayName: item.displayName,
|
||||
description: item.description ?? 'No description available',
|
||||
version: getLatestVersion(item.version),
|
||||
inputs: item.inputs,
|
||||
outputs: item.outputs,
|
||||
score,
|
||||
...(item.builderHint?.searchHint && {
|
||||
builderHintMessage: item.builderHint.searchHint,
|
||||
}),
|
||||
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
|
||||
};
|
||||
},
|
||||
);
|
||||
.map(({ item, score }) => toNodeSearchResult(item, score));
|
||||
setCapped(this.nameSearchCache, cacheKey, results);
|
||||
return cloneSearchResults(results);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -306,38 +369,29 @@ export class CodeBuilderNodeSearchEngine {
|
|||
connectionType: NodeConnectionType,
|
||||
limit: number = 20,
|
||||
nameFilter?: string,
|
||||
): CodeBuilderNodeSearchResult[] {
|
||||
): NodeSearchResult[] {
|
||||
const cacheKey = `${connectionType}\0${limit}\0${nameFilter ?? ''}`;
|
||||
const cached = this.connectionSearchCache.get(cacheKey);
|
||||
if (cached) return cloneSearchResults(cached);
|
||||
|
||||
// First, filter by connection type
|
||||
const nodesWithConnectionType = this.nodeTypes
|
||||
.map((nodeType) => {
|
||||
const connectionScore = this.getConnectionScore(nodeType, connectionType);
|
||||
return connectionScore > 0 ? { nodeType, connectionScore } : null;
|
||||
})
|
||||
.filter((result): result is { nodeType: LeanNodeTypeDescription; connectionScore: number } =>
|
||||
.filter((result): result is { nodeType: SearchableNodeType; connectionScore: number } =>
|
||||
Boolean(result),
|
||||
);
|
||||
|
||||
// If no name filter, return connection matches sorted by score
|
||||
if (!nameFilter) {
|
||||
return nodesWithConnectionType
|
||||
const results = nodesWithConnectionType
|
||||
.sort((a, b) => b.connectionScore - a.connectionScore)
|
||||
.slice(0, limit)
|
||||
.map(({ nodeType, connectionScore }) => {
|
||||
const subnodeRequirements = extractSubnodeRequirements(nodeType.builderHint?.inputs);
|
||||
return {
|
||||
name: nodeType.name,
|
||||
displayName: nodeType.displayName,
|
||||
version: getLatestVersion(nodeType.version),
|
||||
description: nodeType.description ?? 'No description available',
|
||||
inputs: nodeType.inputs,
|
||||
outputs: nodeType.outputs,
|
||||
score: connectionScore,
|
||||
...(nodeType.builderHint?.searchHint && {
|
||||
builderHintMessage: nodeType.builderHint.searchHint,
|
||||
}),
|
||||
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
|
||||
};
|
||||
});
|
||||
.map(({ nodeType, connectionScore }) => toNodeSearchResult(nodeType, connectionScore));
|
||||
setCapped(this.connectionSearchCache, cacheKey, results);
|
||||
return cloneSearchResults(results);
|
||||
}
|
||||
|
||||
// Apply name filter using the same multi-word-aware fuzzy search as searchByName
|
||||
|
|
@ -345,29 +399,15 @@ export class CodeBuilderNodeSearchEngine {
|
|||
const nameFilteredResults = this.fuzzySearchNodes(nameFilter, nodeTypesOnly, limit);
|
||||
|
||||
// Combine connection score with name score
|
||||
return nameFilteredResults
|
||||
.slice(0, limit)
|
||||
.map(({ item, score: nameScore }: { item: LeanNodeTypeDescription; score: number }) => {
|
||||
const connectionResult = nodesWithConnectionType.find(
|
||||
(result) => result.nodeType.name === item.name,
|
||||
);
|
||||
const connectionScore = connectionResult?.connectionScore ?? 0;
|
||||
const subnodeRequirements = extractSubnodeRequirements(item.builderHint?.inputs);
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
version: getLatestVersion(item.version),
|
||||
displayName: item.displayName,
|
||||
description: item.description ?? 'No description available',
|
||||
inputs: item.inputs,
|
||||
outputs: item.outputs,
|
||||
score: connectionScore + nameScore,
|
||||
...(item.builderHint?.searchHint && {
|
||||
builderHintMessage: item.builderHint.searchHint,
|
||||
}),
|
||||
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
|
||||
};
|
||||
});
|
||||
const results = nameFilteredResults.slice(0, limit).map(({ item, score: nameScore }) => {
|
||||
const connectionResult = nodesWithConnectionType.find(
|
||||
(result) => result.nodeType.name === item.name,
|
||||
);
|
||||
const connectionScore = connectionResult?.connectionScore ?? 0;
|
||||
return toNodeSearchResult(item, connectionScore + nameScore);
|
||||
});
|
||||
setCapped(this.connectionSearchCache, cacheKey, results);
|
||||
return cloneSearchResults(results);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -375,7 +415,7 @@ export class CodeBuilderNodeSearchEngine {
|
|||
* @param result - Single search result
|
||||
* @returns XML-formatted string
|
||||
*/
|
||||
formatResult(result: CodeBuilderNodeSearchResult): string {
|
||||
formatResult(result: NodeSearchResult): string {
|
||||
const parts = [
|
||||
' <node>',
|
||||
` <node_name>${result.name}</node_name>`,
|
||||
|
|
@ -385,6 +425,16 @@ export class CodeBuilderNodeSearchEngine {
|
|||
` <node_outputs>${typeof result.outputs === 'object' ? JSON.stringify(result.outputs) : result.outputs}</node_outputs>`,
|
||||
];
|
||||
|
||||
// Flag n8n Connect coverage so the model can prefer it over comparable
|
||||
// alternatives when the user has not named a specific tool.
|
||||
if (result.aiGateway) {
|
||||
const minVersion =
|
||||
result.aiGateway.minVersion !== undefined
|
||||
? ` min_version="${result.aiGateway.minVersion}"`
|
||||
: '';
|
||||
parts.push(` <n8n_credits supported="true"${minVersion} />`);
|
||||
}
|
||||
|
||||
// Add builder hint message if present
|
||||
if (result.builderHintMessage) {
|
||||
parts.push(` <builder_hint>${result.builderHintMessage}</builder_hint>`);
|
||||
|
|
@ -477,7 +527,7 @@ export class CodeBuilderNodeSearchEngine {
|
|||
* @param nodeId - The node ID to look up
|
||||
* @returns The node type description or undefined
|
||||
*/
|
||||
getNodeType(nodeId: string): LeanNodeTypeDescription | undefined {
|
||||
getNodeType(nodeId: string): SearchableNodeType | undefined {
|
||||
return this.nodeTypes.find((n) => n.name === nodeId);
|
||||
}
|
||||
|
||||
|
|
@ -488,7 +538,7 @@ export class CodeBuilderNodeSearchEngine {
|
|||
* @returns Score indicating match quality
|
||||
*/
|
||||
private getConnectionScore(
|
||||
nodeType: LeanNodeTypeDescription,
|
||||
nodeType: SearchableNodeType,
|
||||
connectionType: NodeConnectionType,
|
||||
): number {
|
||||
const outputs = nodeType.outputs;
|
||||
|
|
@ -521,10 +571,7 @@ export class CodeBuilderNodeSearchEngine {
|
|||
* Get all available AI connection types
|
||||
* @returns Array of AI connection types
|
||||
*/
|
||||
static getAiConnectionTypes(): NodeConnectionType[] {
|
||||
return Object.values(NodeConnectionTypes).filter(
|
||||
(type): type is NodeConnectionType =>
|
||||
isNodeConnectionType(type) && CodeBuilderNodeSearchEngine.isAiConnectionType(type),
|
||||
);
|
||||
static getAiConnectionTypes(): readonly AiConnectionType[] {
|
||||
return AI_CONNECTION_TYPES;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,117 @@
|
|||
* `code-builder/types.ts` (the parts not tied to the agent runtime).
|
||||
*/
|
||||
|
||||
import type { IDisplayOptions, INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { AINodeConnectionType, IDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Every AI connection type, as a literal tuple so callers can build a zod enum
|
||||
* from it. Spelled out rather than derived because `z.enum` needs literals.
|
||||
*
|
||||
* Two compile-time guards keep it in step with n8n-workflow, which owns the
|
||||
* canonical list. `satisfies` rejects a member that is not a real connection
|
||||
* type, and {@link AiConnectionTypesAreExhaustive} rejects a real one that is
|
||||
* missing. Both directions matter: this list drifted in each of them before.
|
||||
*/
|
||||
export const AI_CONNECTION_TYPES = [
|
||||
'ai_agent',
|
||||
'ai_chain',
|
||||
'ai_document',
|
||||
'ai_embedding',
|
||||
'ai_languageModel',
|
||||
'ai_memory',
|
||||
'ai_outputParser',
|
||||
'ai_reranker',
|
||||
'ai_retriever',
|
||||
'ai_textSplitter',
|
||||
'ai_tool',
|
||||
'ai_vectorStore',
|
||||
] as const satisfies readonly AINodeConnectionType[];
|
||||
|
||||
export type AiConnectionType = (typeof AI_CONNECTION_TYPES)[number];
|
||||
|
||||
type AssertNever<T extends never> = T;
|
||||
|
||||
/**
|
||||
* Compile-time proof that {@link AI_CONNECTION_TYPES} lists every `ai_*` type.
|
||||
* If n8n-workflow gains one that is missing above, `Exclude` stops resolving to
|
||||
* `never` and this fails its constraint. Type-only, so it costs no runtime code.
|
||||
*/
|
||||
export type AiConnectionTypesAreExhaustive = AssertNever<
|
||||
Exclude<AINodeConnectionType, AiConnectionType>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Metadata about how a node is reachable via the AI Gateway (n8n Connect).
|
||||
* Attached to node results only when the instance is licensed for the gateway
|
||||
* AND the node is listed in the gateway config. Absence means either
|
||||
* "not licensed" or "not supported"; consumers treat both the same.
|
||||
*
|
||||
* Search results carry it so a model can prefer a covered node when the user
|
||||
* has not named a specific integration.
|
||||
*
|
||||
* `operations` mirrors the gateway config's `supportedActions[nodeName]`: a map
|
||||
* from resource name to allowed operations. Nodes without a resource dimension
|
||||
* use the marker key `'__operation_only__'`.
|
||||
*/
|
||||
export interface AiGatewayNodeMeta {
|
||||
supported: true;
|
||||
operations?: Record<string, string[]>;
|
||||
minVersion?: number;
|
||||
hiddenProperties?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Node connection lists as the search engine sees them: either a list of
|
||||
* connection types / configuration objects, or an expression string.
|
||||
*
|
||||
* Deliberately loose. The engine only tests membership and serializes these, so
|
||||
* this accepts both `INodeTypeDescription['inputs']` and the plain `string[]`
|
||||
* shape hosts hand over without either side casting.
|
||||
*/
|
||||
export type SearchableConnections = ReadonlyArray<string | object> | string;
|
||||
|
||||
/** Availability and requirement config for one AI input in `builderHint.inputs`. */
|
||||
export interface SearchableBuilderHintInputConfig {
|
||||
required: boolean;
|
||||
displayOptions?: IDisplayOptions | Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `builderHint.inputs`, keyed by connection type.
|
||||
*
|
||||
* String-keyed rather than keyed by `AINodeConnectionType` so that hosts
|
||||
* supplying a plain record satisfy it alongside n8n-workflow's literal-keyed
|
||||
* `BuilderHintInputs`. The engine only reads `Object.entries`, so the narrower
|
||||
* key type buys it nothing.
|
||||
*/
|
||||
export type SearchableBuilderHintInputs = Record<
|
||||
string,
|
||||
SearchableBuilderHintInputConfig | undefined
|
||||
>;
|
||||
|
||||
/**
|
||||
* The node type shape the search engine reads.
|
||||
*
|
||||
* Only the fields the engine touches are required. The rest are optional so
|
||||
* that both a full `INodeTypeDescription`, the pre-digested
|
||||
* `LeanNodeTypeDescription`, and the leaner shape an embedding host supplies all
|
||||
* satisfy it without conversion.
|
||||
*/
|
||||
export interface SearchableNodeType {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
version: number | number[];
|
||||
inputs: SearchableConnections;
|
||||
outputs: SearchableConnections;
|
||||
/** Only the `alias` array is consumed, by sublimeSearch. */
|
||||
codex?: { alias?: string[] };
|
||||
aiGateway?: AiGatewayNodeMeta;
|
||||
builderHint?: {
|
||||
searchHint?: string;
|
||||
inputs?: SearchableBuilderHintInputs;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a subnode requirement for AI nodes.
|
||||
|
|
@ -17,25 +127,33 @@ export interface SubnodeRequirement {
|
|||
/** Whether this subnode is required */
|
||||
required: boolean;
|
||||
/** Conditions under which this subnode is required (e.g., when hasOutputParser is true) */
|
||||
displayOptions?: IDisplayOptions;
|
||||
displayOptions?: IDisplayOptions | Record<string, unknown>;
|
||||
/**
|
||||
* Preferred node to satisfy this requirement, matching a provider the user
|
||||
* already has a credential for. Set for ai_languageModel when an LLM
|
||||
* credential exists; use it instead of the generic default.
|
||||
*/
|
||||
suggestedNode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node search result with scoring and subnode requirements.
|
||||
*
|
||||
* This is an extended version of the base NodeSearchResult that includes
|
||||
* builder hints and subnode requirements for the code-builder agent.
|
||||
* Extends the plain node fields with a relevance score, builder hints, and
|
||||
* subnode requirements.
|
||||
*/
|
||||
export interface CodeBuilderNodeSearchResult {
|
||||
export interface NodeSearchResult {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
version: number;
|
||||
score: number;
|
||||
inputs: INodeTypeDescription['inputs'];
|
||||
outputs: INodeTypeDescription['outputs'];
|
||||
/** General hint message for workflow builders (from node-level builderHint.searchHint) */
|
||||
inputs: SearchableConnections;
|
||||
outputs: SearchableConnections;
|
||||
/** General hint message for workflow builders (from `builderHint.searchHint`) */
|
||||
builderHintMessage?: string;
|
||||
/** Subnode requirements extracted from builderHint.inputs */
|
||||
subnodeRequirements?: SubnodeRequirement[];
|
||||
/** Present when the node is reachable via n8n Connect on this instance. */
|
||||
aiGateway?: AiGatewayNodeMeta;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,48 +8,7 @@ import type { Callbacks } from '@langchain/core/callbacks/manager';
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
INodeTypeDescription,
|
||||
ITelemetryTrackProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
// ============================================================================
|
||||
// Forked types from types/nodes.ts for code-builder specific features
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Represents a subnode requirement for AI nodes
|
||||
* Extracted from builderHint.inputs on node type descriptions
|
||||
*/
|
||||
export interface SubnodeRequirement {
|
||||
/** The connection type (e.g., 'ai_languageModel', 'ai_memory') */
|
||||
connectionType: string;
|
||||
/** Whether this subnode is required */
|
||||
required: boolean;
|
||||
/** Conditions under which this subnode is required (e.g., when hasOutputParser is true) */
|
||||
displayOptions?: IDisplayOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node search result with scoring and subnode requirements
|
||||
*
|
||||
* This is an extended version of the base NodeSearchResult that includes
|
||||
* builder hints and subnode requirements for the code-builder agent.
|
||||
*/
|
||||
export interface CodeBuilderNodeSearchResult {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
version: number;
|
||||
score: number;
|
||||
inputs: INodeTypeDescription['inputs'];
|
||||
outputs: INodeTypeDescription['outputs'];
|
||||
/** General hint message for workflow builders (from node-level builderHint.searchHint) */
|
||||
builderHintMessage?: string;
|
||||
/** Subnode requirements extracted from builderHint.inputs */
|
||||
subnodeRequirements?: SubnodeRequirement[];
|
||||
}
|
||||
import type { INodeTypeDescription, ITelemetryTrackProperties } from 'n8n-workflow';
|
||||
|
||||
// ============================================================================
|
||||
// Code Builder Agent Types
|
||||
|
|
|
|||
|
|
@ -495,7 +495,7 @@ function coerceCodex(value: unknown): SearchableNodeDescription['codex'] | undef
|
|||
function coerceBuilderHint(value: unknown): SearchableNodeDescription['builderHint'] | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const hint: NonNullable<SearchableNodeDescription['builderHint']> = {};
|
||||
if (typeof value.message === 'string') hint.message = value.message;
|
||||
if (typeof value.searchHint === 'string') hint.searchHint = value.searchHint;
|
||||
const inputs = coerceHintPortMap(value.inputs);
|
||||
if (inputs) hint.inputs = inputs;
|
||||
const outputs = coerceHintPortMap(value.outputs);
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@
|
|||
* Consolidated nodes tool — list, search, describe, type-definition, suggested, explore-resources.
|
||||
*/
|
||||
import { Tool } from '@n8n/agents';
|
||||
import { categoryList, suggestedNodesData } from '@n8n/ai-utilities/node-catalog';
|
||||
import {
|
||||
AI_CONNECTION_TYPES,
|
||||
NodeSearchEngine,
|
||||
categoryList,
|
||||
suggestedNodesData,
|
||||
type SearchableNodeType,
|
||||
} from '@n8n/ai-utilities/node-catalog';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
|
||||
import type { InstanceAiContext } from '../types';
|
||||
import { NodeSearchEngine } from './nodes/node-search-engine';
|
||||
import { AI_CONNECTION_TYPES, type SearchableNodeType } from './nodes/node-search-engine.types';
|
||||
import { pickPreferredChatModelNode } from './nodes/preferred-chat-model';
|
||||
import { buildCredentialMap } from './workflows/resolve-credentials';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,503 +0,0 @@
|
|||
/**
|
||||
* Node Search Engine
|
||||
*
|
||||
* Ported from ai-workflow-builder.ee/code-builder/engines/code-builder-node-search-engine.ts
|
||||
* into the instance-ai package. All n8n-workflow dependencies have been
|
||||
* replaced with local types so the package stays decoupled.
|
||||
*/
|
||||
|
||||
import { sublimeSearch } from '@n8n/utils/search/sublime-search';
|
||||
|
||||
import type {
|
||||
BuilderHintInputs,
|
||||
NodeSearchResult,
|
||||
SearchableNodeType,
|
||||
SubnodeRequirement,
|
||||
} from './node-search-engine.types';
|
||||
import { AI_CONNECTION_TYPES } from './node-search-engine.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Default subnodes for each connection type.
|
||||
* These are sensible defaults shown in search results.
|
||||
*/
|
||||
const DEFAULT_SUBNODES: Record<string, string[]> = {
|
||||
ai_languageModel: ['@n8n/n8n-nodes-langchain.lmChatOpenAi'],
|
||||
ai_memory: ['@n8n/n8n-nodes-langchain.memoryBufferWindow'],
|
||||
ai_embedding: ['@n8n/n8n-nodes-langchain.embeddingsOpenAi'],
|
||||
ai_vectorStore: ['@n8n/n8n-nodes-langchain.vectorStoreInMemory'],
|
||||
// ai_tool is intentionally excluded - varies by use case
|
||||
};
|
||||
|
||||
/**
|
||||
* Search keys configuration for sublimeSearch.
|
||||
* Keys are ordered by importance with corresponding weights.
|
||||
*/
|
||||
const NODE_SEARCH_KEYS = [
|
||||
{ key: 'displayName', weight: 1.5 },
|
||||
{ key: 'name', weight: 1.3 },
|
||||
{ key: 'codex.alias', weight: 1.0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract the short type name from a full node name.
|
||||
* e.g., "n8n-nodes-base.set" -> "set"
|
||||
*/
|
||||
function getTypeName(nodeName: string): string {
|
||||
if (!nodeName) return '';
|
||||
const lastDotIndex = nodeName.lastIndexOf('.');
|
||||
return lastDotIndex >= 0 ? nodeName.substring(lastDotIndex + 1) : nodeName;
|
||||
}
|
||||
|
||||
/** Scoring weights for connection type matching. */
|
||||
export const SCORE_WEIGHTS = {
|
||||
CONNECTION_EXACT: 100,
|
||||
CONNECTION_IN_EXPRESSION: 50,
|
||||
} as const;
|
||||
|
||||
function getLatestVersion(version: number | number[]): number {
|
||||
return Array.isArray(version) ? Math.max(...version) : version;
|
||||
}
|
||||
|
||||
/** Extract subnode requirements from builderHint.inputs. */
|
||||
function extractSubnodeRequirements(inputs?: BuilderHintInputs): SubnodeRequirement[] {
|
||||
if (!inputs) return [];
|
||||
|
||||
return Object.entries(inputs)
|
||||
.filter((entry): entry is [string, NonNullable<(typeof entry)[1]>] => entry[1] !== null)
|
||||
.map(([connectionType, config]) => ({
|
||||
connectionType,
|
||||
required: config.required,
|
||||
...(config.displayOptions && { displayOptions: config.displayOptions }),
|
||||
}));
|
||||
}
|
||||
|
||||
function getBuilderHintMessage(
|
||||
builderHint?: SearchableNodeType['builderHint'],
|
||||
): string | undefined {
|
||||
return builderHint?.message ?? builderHint?.searchHint;
|
||||
}
|
||||
|
||||
function toNodeSearchResult(node: SearchableNodeType, score: number): NodeSearchResult {
|
||||
const subnodeRequirements = extractSubnodeRequirements(node.builderHint?.inputs);
|
||||
const builderHintMessage = getBuilderHintMessage(node.builderHint);
|
||||
|
||||
return {
|
||||
name: node.name,
|
||||
displayName: node.displayName,
|
||||
description: node.description ?? 'No description available',
|
||||
version: getLatestVersion(node.version),
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
score,
|
||||
...(builderHintMessage && { builderHintMessage }),
|
||||
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
|
||||
...(node.aiGateway && { aiGateway: node.aiGateway }),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeNodes(nodes: SearchableNodeType[]): SearchableNodeType[] {
|
||||
const dedupeCache: Record<string, SearchableNodeType> = {};
|
||||
nodes.forEach((node) => {
|
||||
const cachedNodeType = dedupeCache[node.name];
|
||||
if (!cachedNodeType) {
|
||||
dedupeCache[node.name] = node;
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedVersion = getLatestVersion(cachedNodeType.version);
|
||||
const nextVersion = getLatestVersion(node.version);
|
||||
|
||||
if (nextVersion > cachedVersion) {
|
||||
dedupeCache[node.name] = node;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(dedupeCache);
|
||||
}
|
||||
|
||||
function cloneSearchResult(result: NodeSearchResult): NodeSearchResult {
|
||||
return {
|
||||
...result,
|
||||
...(result.subnodeRequirements
|
||||
? {
|
||||
subnodeRequirements: result.subnodeRequirements.map((requirement) => ({
|
||||
...requirement,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSearchResults(results: NodeSearchResult[]): NodeSearchResult[] {
|
||||
return results.map(cloneSearchResult);
|
||||
}
|
||||
|
||||
function descriptionMatchScore(
|
||||
description: string | undefined,
|
||||
queryLower: string,
|
||||
queryTerms: string[],
|
||||
): number {
|
||||
if (!description) return 0;
|
||||
const descriptionLower = description.toLowerCase();
|
||||
if (queryTerms.length === 0) return descriptionLower.includes(queryLower) ? 1 : 0;
|
||||
let matches = 0;
|
||||
for (const term of queryTerms) {
|
||||
if (descriptionLower.includes(term)) matches += 1;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pure business logic for searching nodes.
|
||||
* Separated from tool infrastructure for better testability.
|
||||
*/
|
||||
export class NodeSearchEngine {
|
||||
private readonly nodeTypes: SearchableNodeType[];
|
||||
|
||||
private readonly nameSearchCache = new Map<string, NodeSearchResult[]>();
|
||||
|
||||
private readonly connectionSearchCache = new Map<string, NodeSearchResult[]>();
|
||||
|
||||
constructor(nodeTypes: SearchableNodeType[]) {
|
||||
this.nodeTypes = dedupeNodes(nodeTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy-search a list of nodes by query, handling multi-word queries by
|
||||
* splitting into individual terms and merging results. Falls back to
|
||||
* direct type-name / display-name matching for nodes the fuzzy search missed.
|
||||
*/
|
||||
private fuzzySearchNodes(
|
||||
query: string,
|
||||
candidates: SearchableNodeType[],
|
||||
limit?: number,
|
||||
): Array<{ item: SearchableNodeType; score: number }> {
|
||||
const queryLower = query.toLowerCase().trim();
|
||||
const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 1);
|
||||
const isMultiWord = queryTerms.length > 1;
|
||||
|
||||
// For multi-word queries, search each term individually then merge.
|
||||
// sublimeSearch breaks down on multi-word queries because it tries to
|
||||
// fuzzy-match the entire string as one character sequence.
|
||||
type ScoredNode = { item: SearchableNodeType; score: number };
|
||||
let searchResults: ScoredNode[];
|
||||
|
||||
if (isMultiWord) {
|
||||
const scoreMap = new Map<string, ScoredNode>();
|
||||
for (const term of queryTerms) {
|
||||
const termResults = sublimeSearch<SearchableNodeType>(
|
||||
term,
|
||||
candidates,
|
||||
NODE_SEARCH_KEYS,
|
||||
).slice(0, limit);
|
||||
for (const r of termResults) {
|
||||
const existing = scoreMap.get(r.item.name);
|
||||
if (!existing || r.score > existing.score) {
|
||||
scoreMap.set(r.item.name, { item: r.item, score: r.score });
|
||||
}
|
||||
}
|
||||
}
|
||||
searchResults = [...scoreMap.values()];
|
||||
} else {
|
||||
searchResults = sublimeSearch<SearchableNodeType>(query, candidates, NODE_SEARCH_KEYS);
|
||||
}
|
||||
|
||||
const fuzzyResultNames = new Set(searchResults.map((r) => r.item.name));
|
||||
const remainingDescriptionMatches =
|
||||
limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, limit - searchResults.length);
|
||||
if (remainingDescriptionMatches > 0) {
|
||||
const descriptionResults: ScoredNode[] = [];
|
||||
for (const item of candidates) {
|
||||
if (fuzzyResultNames.has(item.name)) continue;
|
||||
const score = descriptionMatchScore(item.description, queryLower, queryTerms);
|
||||
if (score === 0) continue;
|
||||
descriptionResults.push({ item, score });
|
||||
fuzzyResultNames.add(item.name);
|
||||
if (descriptionResults.length >= remainingDescriptionMatches) break;
|
||||
}
|
||||
searchResults = [...searchResults, ...descriptionResults];
|
||||
}
|
||||
|
||||
// Direct type name / display name match — catches nodes fuzzy search missed
|
||||
const typeNameMatches = candidates
|
||||
.filter((node) => {
|
||||
if (fuzzyResultNames.has(node.name)) return false;
|
||||
const typeName = getTypeName(node.name).toLowerCase();
|
||||
const displayName = node.displayName.toLowerCase();
|
||||
if (typeName === queryLower || displayName === queryLower) return true;
|
||||
return queryTerms.some(
|
||||
(term) =>
|
||||
typeName === term ||
|
||||
typeName.includes(term) ||
|
||||
displayName === term ||
|
||||
displayName.includes(term),
|
||||
);
|
||||
})
|
||||
.map((item) => ({ item, score: 0 }));
|
||||
|
||||
// Merge and sort: exact type/display name matches first, then by fuzzy score
|
||||
const allResults = [...searchResults, ...typeNameMatches];
|
||||
allResults.sort((a, b) => {
|
||||
const typeNameA = getTypeName(a.item.name).toLowerCase();
|
||||
const displayNameA = a.item.displayName.toLowerCase();
|
||||
const typeNameB = getTypeName(b.item.name).toLowerCase();
|
||||
const displayNameB = b.item.displayName.toLowerCase();
|
||||
const exactA =
|
||||
typeNameA === queryLower ||
|
||||
displayNameA === queryLower ||
|
||||
queryTerms.some((t) => typeNameA === t || displayNameA === t);
|
||||
const exactB =
|
||||
typeNameB === queryLower ||
|
||||
displayNameB === queryLower ||
|
||||
queryTerms.some((t) => typeNameB === t || displayNameB === t);
|
||||
if (exactA && !exactB) return -1;
|
||||
if (!exactA && exactB) return 1;
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search nodes by name, display name, or description.
|
||||
* Always returns the latest version of a node.
|
||||
* @param query - The search query string
|
||||
* @param limit - Maximum number of results to return
|
||||
* @returns Array of matching nodes sorted by relevance
|
||||
*/
|
||||
searchByName(query: string, limit: number = 20): NodeSearchResult[] {
|
||||
const cacheKey = `${query}\0${limit}`;
|
||||
const cached = this.nameSearchCache.get(cacheKey);
|
||||
if (cached) return cloneSearchResults(cached);
|
||||
|
||||
const results = this.fuzzySearchNodes(query, this.nodeTypes, limit)
|
||||
.slice(0, limit)
|
||||
.map(({ item, score }) => toNodeSearchResult(item, score));
|
||||
this.nameSearchCache.set(cacheKey, results);
|
||||
return cloneSearchResults(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for sub-nodes that output a specific connection type.
|
||||
* Always returns the latest version of a node.
|
||||
* @param connectionType - The connection type to search for
|
||||
* @param limit - Maximum number of results
|
||||
* @param nameFilter - Optional name filter
|
||||
* @returns Array of matching sub-nodes
|
||||
*/
|
||||
searchByConnectionType(
|
||||
connectionType: string,
|
||||
limit: number = 20,
|
||||
nameFilter?: string,
|
||||
): NodeSearchResult[] {
|
||||
const cacheKey = `${connectionType}\0${limit}\0${nameFilter ?? ''}`;
|
||||
const cached = this.connectionSearchCache.get(cacheKey);
|
||||
if (cached) return cloneSearchResults(cached);
|
||||
|
||||
// First, filter by connection type
|
||||
const nodesWithConnectionType = this.nodeTypes
|
||||
.map((nodeType) => {
|
||||
const connectionScore = this.getConnectionScore(nodeType, connectionType);
|
||||
return connectionScore > 0 ? { nodeType, connectionScore } : null;
|
||||
})
|
||||
.filter((result): result is { nodeType: SearchableNodeType; connectionScore: number } =>
|
||||
Boolean(result),
|
||||
);
|
||||
|
||||
// If no name filter, return connection matches sorted by score
|
||||
if (!nameFilter) {
|
||||
const results = nodesWithConnectionType
|
||||
.sort((a, b) => b.connectionScore - a.connectionScore)
|
||||
.slice(0, limit)
|
||||
.map(({ nodeType, connectionScore }) => toNodeSearchResult(nodeType, connectionScore));
|
||||
this.connectionSearchCache.set(cacheKey, results);
|
||||
return cloneSearchResults(results);
|
||||
}
|
||||
|
||||
// Apply name filter using the same multi-word-aware fuzzy search as searchByName
|
||||
const nodeTypesOnly = nodesWithConnectionType.map((result) => result.nodeType);
|
||||
const nameFilteredResults = this.fuzzySearchNodes(nameFilter, nodeTypesOnly, limit);
|
||||
|
||||
// Combine connection score with name score
|
||||
const results = nameFilteredResults.slice(0, limit).map(({ item, score: nameScore }) => {
|
||||
const connectionResult = nodesWithConnectionType.find(
|
||||
(result) => result.nodeType.name === item.name,
|
||||
);
|
||||
const connectionScore = connectionResult?.connectionScore ?? 0;
|
||||
return toNodeSearchResult(item, connectionScore + nameScore);
|
||||
});
|
||||
this.connectionSearchCache.set(cacheKey, results);
|
||||
return cloneSearchResults(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for tool output.
|
||||
* @param result - Single search result
|
||||
* @returns XML-formatted string
|
||||
*/
|
||||
formatResult(result: NodeSearchResult): string {
|
||||
const parts = [
|
||||
' <node>',
|
||||
` <node_name>${result.name}</node_name>`,
|
||||
` <node_version>${result.version}</node_version>`,
|
||||
` <node_description>${result.description}</node_description>`,
|
||||
` <node_inputs>${typeof result.inputs === 'object' ? JSON.stringify(result.inputs) : result.inputs}</node_inputs>`,
|
||||
` <node_outputs>${typeof result.outputs === 'object' ? JSON.stringify(result.outputs) : result.outputs}</node_outputs>`,
|
||||
];
|
||||
|
||||
// Flag n8n Connect coverage so the model can prefer it over comparable
|
||||
// alternatives when the user has not named a specific tool.
|
||||
if (result.aiGateway) {
|
||||
const minVersion =
|
||||
result.aiGateway.minVersion !== undefined
|
||||
? ` min_version="${result.aiGateway.minVersion}"`
|
||||
: '';
|
||||
parts.push(` <n8n_credits supported="true"${minVersion} />`);
|
||||
}
|
||||
|
||||
// Add builder hint message if present
|
||||
if (result.builderHintMessage) {
|
||||
parts.push(` <builder_hint>${result.builderHintMessage}</builder_hint>`);
|
||||
}
|
||||
|
||||
// Add subnode requirements if present
|
||||
if (result.subnodeRequirements && result.subnodeRequirements.length > 0) {
|
||||
parts.push(' <subnode_requirements>');
|
||||
for (const req of result.subnodeRequirements) {
|
||||
const requiredStr = req.required ? 'required' : 'optional';
|
||||
if (req.displayOptions) {
|
||||
parts.push(
|
||||
` <requirement type="${req.connectionType}" status="${requiredStr}"><display_options>${JSON.stringify(req.displayOptions)}</display_options></requirement>`,
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
' <requirement type="' +
|
||||
req.connectionType +
|
||||
'" status="' +
|
||||
requiredStr +
|
||||
'" />',
|
||||
);
|
||||
}
|
||||
}
|
||||
parts.push(' </subnode_requirements>');
|
||||
}
|
||||
|
||||
parts.push(' </node>');
|
||||
|
||||
return '\n' + parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subnodes that output a specific connection type.
|
||||
* Uses default subnodes for common connection types.
|
||||
* @param connectionType - The connection type to find subnodes for
|
||||
* @returns Array of node IDs that can satisfy this connection type
|
||||
*/
|
||||
getSubnodesForConnectionType(connectionType: string): string[] {
|
||||
// Return defaults for this connection type if available
|
||||
// ai_tool is excluded - it varies by use case
|
||||
return DEFAULT_SUBNODES[connectionType] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect related subnode IDs for nodes with builderHint.inputs.
|
||||
* For each connection type requirement, finds default subnodes and their
|
||||
* transitive dependencies.
|
||||
* @param nodeIds - Initial node IDs to get related subnodes for
|
||||
* @param excludeNodeIds - Node IDs to exclude (already shown in results)
|
||||
* @returns Set of related node IDs
|
||||
*/
|
||||
getRelatedSubnodeIds(nodeIds: string[], excludeNodeIds: Set<string>): Set<string> {
|
||||
const allRelated = new Set<string>();
|
||||
const visited = new Set<string>(excludeNodeIds);
|
||||
|
||||
// Mark initial nodes as visited
|
||||
for (const nodeId of nodeIds) {
|
||||
visited.add(nodeId);
|
||||
}
|
||||
|
||||
// Process queue of nodes to check for subnode requirements
|
||||
const queue = [...nodeIds];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentNodeId = queue.shift()!;
|
||||
const nodeType = this.nodeTypes.find((n) => n.name === currentNodeId);
|
||||
if (!nodeType?.builderHint?.inputs) continue;
|
||||
|
||||
// For each connection type in builderHint.inputs, get default subnodes
|
||||
for (const connectionType of Object.keys(nodeType.builderHint.inputs)) {
|
||||
const subnodeIds = this.getSubnodesForConnectionType(connectionType);
|
||||
|
||||
for (const subnodeId of subnodeIds) {
|
||||
if (visited.has(subnodeId)) continue;
|
||||
|
||||
visited.add(subnodeId);
|
||||
allRelated.add(subnodeId);
|
||||
|
||||
// Add to queue for recursive processing
|
||||
queue.push(subnodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allRelated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node type by ID.
|
||||
* @param nodeId - The node ID to look up
|
||||
* @returns The node type description or undefined
|
||||
*/
|
||||
getNodeType(nodeId: string): SearchableNodeType | undefined {
|
||||
return this.nodeTypes.find((n) => n.name === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node has a specific connection type in outputs.
|
||||
* @param nodeType - Node type to check
|
||||
* @param connectionType - Connection type to look for
|
||||
* @returns Score indicating match quality
|
||||
*/
|
||||
private getConnectionScore(nodeType: SearchableNodeType, connectionType: string): number {
|
||||
const outputs = nodeType.outputs;
|
||||
|
||||
if (Array.isArray(outputs)) {
|
||||
// Direct array match
|
||||
if (outputs.includes(connectionType)) {
|
||||
return SCORE_WEIGHTS.CONNECTION_EXACT;
|
||||
}
|
||||
} else if (typeof outputs === 'string') {
|
||||
// Expression string - check if it contains the connection type
|
||||
if (outputs.includes(connectionType)) {
|
||||
return SCORE_WEIGHTS.CONNECTION_IN_EXPRESSION;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a connection type is an AI connection type.
|
||||
* @param connectionType - Connection type to validate
|
||||
* @returns True if it's an AI connection type
|
||||
*/
|
||||
static isAiConnectionType(connectionType: string): boolean {
|
||||
return connectionType.startsWith('ai_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available AI connection types.
|
||||
* @returns Array of AI connection type strings
|
||||
*/
|
||||
static getAiConnectionTypes(): readonly string[] {
|
||||
return AI_CONNECTION_TYPES;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
/**
|
||||
* Local types for the NodeSearchEngine.
|
||||
*
|
||||
* These are lightweight copies of the n8n-workflow types used by the search
|
||||
* engine. The instance-ai package intentionally does NOT depend on
|
||||
* n8n-workflow, so we keep a minimal, self-contained subset here.
|
||||
*/
|
||||
|
||||
import type { AiGatewayNodeMeta } from '../../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI connection types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** All AI connection type values (mirrors NodeConnectionTypes from n8n-workflow). */
|
||||
export const AI_CONNECTION_TYPES = [
|
||||
'ai_agent',
|
||||
'ai_chain',
|
||||
'ai_document',
|
||||
'ai_embedding',
|
||||
'ai_languageModel',
|
||||
'ai_memory',
|
||||
'ai_outputParser',
|
||||
'ai_retriever',
|
||||
'ai_textSplitter',
|
||||
'ai_tool',
|
||||
'ai_vectorRetriever',
|
||||
'ai_vectorStore',
|
||||
] as const;
|
||||
|
||||
export type AiConnectionType = (typeof AI_CONNECTION_TYPES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builder hint types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Configuration for a single AI subnode input in builderHint.inputs. */
|
||||
export interface BuilderHintInputConfig {
|
||||
/** Whether this AI input is required. */
|
||||
required: boolean;
|
||||
/** Conditions under which this input should be available / required. */
|
||||
displayOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Maps AI input types to their configuration. */
|
||||
export type BuilderHintInputs = Partial<Record<string, BuilderHintInputConfig>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Searchable node type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subset of INodeTypeDescription used by the search engine.
|
||||
*
|
||||
* Only the fields that the engine actually reads are included so that
|
||||
* callers can satisfy this interface without pulling in the full
|
||||
* INodeTypeDescription from n8n-workflow.
|
||||
*/
|
||||
export interface SearchableNodeType {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
version: number | number[];
|
||||
inputs: string[] | string;
|
||||
outputs: string[] | string;
|
||||
/** Present when the node is reachable via n8n Connect on this instance. */
|
||||
aiGateway?: AiGatewayNodeMeta;
|
||||
codex?: {
|
||||
alias?: string[];
|
||||
};
|
||||
builderHint?: {
|
||||
message?: string;
|
||||
searchHint?: string;
|
||||
inputs?: BuilderHintInputs;
|
||||
/**
|
||||
* Multi-line content variations emitted into generated `.d.ts` only;
|
||||
* intentionally ignored by the search engine to keep results lightweight.
|
||||
*/
|
||||
extraTypeDefContent?: Array<{
|
||||
content: string;
|
||||
displayOptions?: {
|
||||
show?: Record<string, unknown[]>;
|
||||
hide?: Record<string, unknown[]>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Subnode requirement extracted from builderHint.inputs. */
|
||||
export interface SubnodeRequirement {
|
||||
/** The connection type (e.g., 'ai_languageModel', 'ai_memory'). */
|
||||
connectionType: string;
|
||||
/** Whether this subnode is required. */
|
||||
required: boolean;
|
||||
/** Conditions under which this subnode is required. */
|
||||
displayOptions?: Record<string, unknown>;
|
||||
/**
|
||||
* Preferred node to satisfy this requirement, matching a provider the user
|
||||
* already has a credential for. Set for ai_languageModel when an LLM
|
||||
* credential exists; use it instead of the generic default.
|
||||
*/
|
||||
suggestedNode?: string;
|
||||
}
|
||||
|
||||
/** Node search result with scoring and subnode requirements. */
|
||||
export interface NodeSearchResult {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
version: number;
|
||||
score: number;
|
||||
inputs: string[] | string;
|
||||
outputs: string[] | string;
|
||||
/** General hint message for workflow builders (from builderHint.message/searchHint). */
|
||||
builderHintMessage?: string;
|
||||
/** Subnode requirements extracted from builderHint.inputs. */
|
||||
subnodeRequirements?: SubnodeRequirement[];
|
||||
/** Present when the node is reachable via n8n Connect on this instance. */
|
||||
aiGateway?: AiGatewayNodeMeta;
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
Telemetry,
|
||||
Workspace,
|
||||
} from '@n8n/agents';
|
||||
import type { AiGatewayNodeMeta } from '@n8n/ai-utilities/node-catalog';
|
||||
import type {
|
||||
AgentJsonConfig,
|
||||
AgentSkill,
|
||||
|
|
@ -233,21 +234,11 @@ export interface NodeSummary {
|
|||
}
|
||||
|
||||
/**
|
||||
* Metadata about how a node is reachable via the AI Gateway (n8n Connect).
|
||||
* Attached to node results only when the instance is licensed for the
|
||||
* gateway AND the node is listed in the gateway config. Absence means either
|
||||
* "not licensed" or "not supported" — consumers treat both the same.
|
||||
*
|
||||
* `operations` mirrors the gateway config's `supportedActions[nodeName]`:
|
||||
* a map from resource name to allowed operations. Nodes without a resource
|
||||
* dimension use the marker key `'__operation_only__'`.
|
||||
* Re-exported from `@n8n/ai-utilities/node-catalog`, which owns the definition
|
||||
* because the shared node search engine reads it. Kept exported here so this
|
||||
* package's consumers have one import site for the Instance AI types.
|
||||
*/
|
||||
export interface AiGatewayNodeMeta {
|
||||
supported: true;
|
||||
operations?: Record<string, string[]>;
|
||||
minVersion?: number;
|
||||
hiddenProperties?: string[];
|
||||
}
|
||||
export type { AiGatewayNodeMeta };
|
||||
|
||||
export interface NodeDescription extends NodeSummary {
|
||||
properties: Array<{
|
||||
|
|
@ -623,7 +614,7 @@ export interface SearchableNodeDescription {
|
|||
outputs: string[] | string;
|
||||
codex?: { alias?: string[] };
|
||||
builderHint?: {
|
||||
message?: string;
|
||||
searchHint?: string;
|
||||
inputs?: Record<string, { required: boolean; displayOptions?: Record<string, unknown> }>;
|
||||
outputs?: Record<string, { required?: boolean; displayOptions?: Record<string, unknown> }>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2537,7 +2537,7 @@ export class InstanceAiAdapterService {
|
|||
if (n.builderHint) {
|
||||
result.builderHint = {};
|
||||
if (n.builderHint.searchHint) {
|
||||
result.builderHint.message = n.builderHint.searchHint;
|
||||
result.builderHint.searchHint = n.builderHint.searchHint;
|
||||
}
|
||||
if (n.builderHint.inputs) {
|
||||
const inputs: Record<
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user