mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
refactor(core): Move node-specific builder guidance to per-node @builderHint (no-changelog) (#29992)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+149
@@ -3,6 +3,154 @@
|
||||
exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = `
|
||||
{
|
||||
"builderHint": {
|
||||
"extraTypeDefContent": [
|
||||
{
|
||||
"content": "Sits on the main flow — pipe the documents you want to embed into this node. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\` and \`documentLoader\`. If the goal is letting an LLM query the store, use \`mode: 'retrieve-as-tool'\` instead.
|
||||
<patterns>
|
||||
<pattern title="insert mode — upsert documents (generic, works for any vectorStore* node)">
|
||||
// Substitute the type literal and provider-specific parameters (e.g. pineconeIndex,
|
||||
// qdrantCollection, supabaseTableName) — see the rest of this file for the exact shape.
|
||||
const store = vectorStore({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: {
|
||||
mode: 'insert',
|
||||
// ...provider-specific parameters
|
||||
},
|
||||
subnodes: { embedding: embeddingsOpenAi, documentLoader: defaultDataLoader }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"insert",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"content": "Canonical RAG mode — declare with the \`tool({...})\` factory (NOT \`vectorStore\`) and plug into an AI Agent's \`subnodes.tools\`. Required subnodes: \`embedding\`. Set \`toolDescription\` so the agent knows when to call it.
|
||||
<patterns>
|
||||
<pattern title="retrieve-as-tool mode — RAG via AI Agent (generic, works for any vectorStore* node)">
|
||||
// Substitute the type literal and provider-specific parameters — see the rest of this file
|
||||
// for the exact shape (e.g. pineconeIndex, qdrantCollection, supabaseTableName).
|
||||
const knowledgeBase = tool({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: {
|
||||
mode: 'retrieve-as-tool',
|
||||
toolDescription: 'Search the product knowledge base',
|
||||
// ...provider-specific parameters
|
||||
},
|
||||
subnodes: { embedding: embeddingsOpenAi }
|
||||
}
|
||||
});
|
||||
|
||||
const agent = node({
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
config: {
|
||||
name: 'Support Agent',
|
||||
parameters: { promptType: 'define', text: expr('{{ $json.question }}') },
|
||||
subnodes: { model: openAiModel, tools: [knowledgeBase] }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"retrieve-as-tool",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"content": "One-shot similarity search on the main flow using the \`prompt\` parameter. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For LLM-driven querying (RAG), use \`mode: 'retrieve-as-tool'\` instead.
|
||||
<patterns>
|
||||
<pattern title="load mode — one-shot similarity search (generic)">
|
||||
// Substitute the type literal and provider-specific parameters — see the rest of this file.
|
||||
const lookup = vectorStore({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: {
|
||||
mode: 'load',
|
||||
prompt: expr('{{ $json.query }}'),
|
||||
// ...provider-specific parameters
|
||||
},
|
||||
subnodes: { embedding: embeddingsOpenAi }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"load",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"content": "Exposes the store as an \`ai_vectorStore\` subnode for another node (e.g. \`toolVectorStore\`). Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For RAG with an AI Agent directly, prefer \`mode: 'retrieve-as-tool'\`.
|
||||
<patterns>
|
||||
<pattern title="retrieve mode — feed another node as a subnode (generic)">
|
||||
// Substitute the type literal and provider-specific parameters — see the rest of this file.
|
||||
const store = vectorStore({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: { mode: 'retrieve' /* + provider-specific parameters */ },
|
||||
subnodes: { embedding: embeddingsOpenAi }
|
||||
}
|
||||
});
|
||||
|
||||
const retrieverTool = tool({
|
||||
type: '@n8n/n8n-nodes-langchain.toolVectorStore',
|
||||
config: {
|
||||
name: 'KB Retriever',
|
||||
parameters: { description: 'Search the product knowledge base' },
|
||||
subnodes: { vectorStore: store, model: openAiModel }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"retrieve",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"content": "Updates a single document by \`id\`. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. Only available on stores whose \`operationModes\` enables it — most providers omit this mode.
|
||||
<patterns>
|
||||
<pattern title="update mode — update document by ID (generic)">
|
||||
// Substitute the type literal and provider-specific parameters — see the rest of this file.
|
||||
const store = vectorStore({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: { mode: 'update', id: expr('{{ $json.docId }}') },
|
||||
subnodes: { embedding: embeddingsOpenAi }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"update",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"inputs": {
|
||||
"ai_document": {
|
||||
"displayOptions": {
|
||||
@@ -66,6 +214,7 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] =
|
||||
},
|
||||
},
|
||||
},
|
||||
"searchHint": "Pick mode by where data flows: \`insert\` upserts documents into the store on the main flow; \`load\` runs a one-shot similarity search on the main flow; \`retrieve-as-tool\` is the canonical RAG mode — plug into an AI Agent's \`subnodes.tools\`; \`retrieve\` exposes the store as a subnode for another node's \`subnodes.vectorStore\`; \`update\` updates a single document by ID.",
|
||||
},
|
||||
"codex": {
|
||||
"categories": [
|
||||
|
||||
@@ -10,6 +10,10 @@ export const DEFAULT_OPERATION_MODES: NodeOperationMode[] = [
|
||||
'retrieve-as-tool',
|
||||
];
|
||||
|
||||
// `mode` is a discriminator field, so per-option `builderHint`s here would never
|
||||
// surface in the generated `.d.ts` (discriminator props are dropped from narrowed
|
||||
// types). Per-mode guidance lives as node-level `extraTypeDefContent` variations
|
||||
// in `createVectorStoreNode.ts`, which the codegen routes per-combo.
|
||||
export const OPERATION_MODE_DESCRIPTIONS: INodePropertyOptions[] = [
|
||||
{
|
||||
name: 'Get Many',
|
||||
|
||||
+120
@@ -77,7 +77,127 @@ export const createVectorStoreNode = <T extends VectorStore = VectorStore>(
|
||||
},
|
||||
},
|
||||
builderHint: {
|
||||
searchHint:
|
||||
"Pick mode by where data flows: `insert` upserts documents into the store on the main flow; `load` runs a one-shot similarity search on the main flow; `retrieve-as-tool` is the canonical RAG mode — plug into an AI Agent's `subnodes.tools`; `retrieve` exposes the store as a subnode for another node's `subnodes.vectorStore`; `update` updates a single document by ID.",
|
||||
...args.meta.builderHint,
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
displayOptions: { show: { mode: ['insert'] } },
|
||||
content: `Sits on the main flow — pipe the documents you want to embed into this node. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\` and \`documentLoader\`. If the goal is letting an LLM query the store, use \`mode: 'retrieve-as-tool'\` instead.
|
||||
<patterns>
|
||||
<pattern title="insert mode — upsert documents (generic, works for any vectorStore* node)">
|
||||
// Substitute the type literal and provider-specific parameters (e.g. pineconeIndex,
|
||||
// qdrantCollection, supabaseTableName) — see the rest of this file for the exact shape.
|
||||
const store = vectorStore({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: {
|
||||
mode: 'insert',
|
||||
// ...provider-specific parameters
|
||||
},
|
||||
subnodes: { embedding: embeddingsOpenAi, documentLoader: defaultDataLoader }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
{
|
||||
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
|
||||
content: `Canonical RAG mode — declare with the \`tool({...})\` factory (NOT \`vectorStore\`) and plug into an AI Agent's \`subnodes.tools\`. Required subnodes: \`embedding\`. Set \`toolDescription\` so the agent knows when to call it.
|
||||
<patterns>
|
||||
<pattern title="retrieve-as-tool mode — RAG via AI Agent (generic, works for any vectorStore* node)">
|
||||
// Substitute the type literal and provider-specific parameters — see the rest of this file
|
||||
// for the exact shape (e.g. pineconeIndex, qdrantCollection, supabaseTableName).
|
||||
const knowledgeBase = tool({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: {
|
||||
mode: 'retrieve-as-tool',
|
||||
toolDescription: 'Search the product knowledge base',
|
||||
// ...provider-specific parameters
|
||||
},
|
||||
subnodes: { embedding: embeddingsOpenAi }
|
||||
}
|
||||
});
|
||||
|
||||
const agent = node({
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
config: {
|
||||
name: 'Support Agent',
|
||||
parameters: { promptType: 'define', text: expr('{{ $json.question }}') },
|
||||
subnodes: { model: openAiModel, tools: [knowledgeBase] }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
{
|
||||
displayOptions: { show: { mode: ['load'] } },
|
||||
content: `One-shot similarity search on the main flow using the \`prompt\` parameter. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For LLM-driven querying (RAG), use \`mode: 'retrieve-as-tool'\` instead.
|
||||
<patterns>
|
||||
<pattern title="load mode — one-shot similarity search (generic)">
|
||||
// Substitute the type literal and provider-specific parameters — see the rest of this file.
|
||||
const lookup = vectorStore({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: {
|
||||
mode: 'load',
|
||||
prompt: expr('{{ $json.query }}'),
|
||||
// ...provider-specific parameters
|
||||
},
|
||||
subnodes: { embedding: embeddingsOpenAi }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
{
|
||||
displayOptions: { show: { mode: ['retrieve'] } },
|
||||
content: `Exposes the store as an \`ai_vectorStore\` subnode for another node (e.g. \`toolVectorStore\`). Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For RAG with an AI Agent directly, prefer \`mode: 'retrieve-as-tool'\`.
|
||||
<patterns>
|
||||
<pattern title="retrieve mode — feed another node as a subnode (generic)">
|
||||
// Substitute the type literal and provider-specific parameters — see the rest of this file.
|
||||
const store = vectorStore({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: { mode: 'retrieve' /* + provider-specific parameters */ },
|
||||
subnodes: { embedding: embeddingsOpenAi }
|
||||
}
|
||||
});
|
||||
|
||||
const retrieverTool = tool({
|
||||
type: '@n8n/n8n-nodes-langchain.toolVectorStore',
|
||||
config: {
|
||||
name: 'KB Retriever',
|
||||
parameters: { description: 'Search the product knowledge base' },
|
||||
subnodes: { vectorStore: store, model: openAiModel }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
{
|
||||
displayOptions: { show: { mode: ['update'] } },
|
||||
content: `Updates a single document by \`id\`. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. Only available on stores whose \`operationModes\` enables it — most providers omit this mode.
|
||||
<patterns>
|
||||
<pattern title="update mode — update document by ID (generic)">
|
||||
// Substitute the type literal and provider-specific parameters — see the rest of this file.
|
||||
const store = vectorStore({
|
||||
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
|
||||
config: {
|
||||
name: 'Knowledge Base',
|
||||
parameters: { mode: 'update', id: expr('{{ $json.docId }}') },
|
||||
subnodes: { embedding: embeddingsOpenAi }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
inputs: {
|
||||
ai_embedding: { required: true },
|
||||
ai_document: {
|
||||
|
||||
@@ -75,7 +75,7 @@ function collectAgents(): AgentEntry[] {
|
||||
researchMode: true,
|
||||
webhookBaseUrl: 'https://your-instance.example.com',
|
||||
filesystemAccess: true,
|
||||
localGateway: { status: 'connected' },
|
||||
localGateway: { status: 'connected', capabilities: ['filesystem', 'browser'] },
|
||||
toolSearchEnabled: true,
|
||||
licenseHints: ['<sample license hint — replace with real hint at runtime>'],
|
||||
timeZone: 'UTC',
|
||||
@@ -101,10 +101,7 @@ function collectAgents(): AgentEntry[] {
|
||||
"localGateway disconnected with filesystem + browser capabilities — renders the 'install Computer Use' pitch and 'Browser Automation (Unavailable)' note",
|
||||
body: getSystemPrompt({
|
||||
webhookBaseUrl: 'https://your-instance.example.com',
|
||||
localGateway: {
|
||||
status: 'disconnected',
|
||||
capabilities: ['filesystem', 'browser'],
|
||||
},
|
||||
localGateway: { status: 'disconnected' },
|
||||
browserAvailable: false,
|
||||
}),
|
||||
},
|
||||
@@ -115,7 +112,7 @@ function collectAgents(): AgentEntry[] {
|
||||
body: getSystemPrompt({
|
||||
webhookBaseUrl: 'https://your-instance.example.com',
|
||||
filesystemAccess: true,
|
||||
localGateway: { status: 'connected' },
|
||||
localGateway: { status: 'connected', capabilities: ['filesystem'] },
|
||||
browserAvailable: false,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -50,6 +50,12 @@ const agentNode = makeNode({
|
||||
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>',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -171,6 +177,21 @@ describe('NodeSearchEngine', () => {
|
||||
expect(agentResult?.builderHintMessage).toBe('Use an AI Agent for autonomous task execution');
|
||||
});
|
||||
|
||||
it('should NOT surface builderHint.extraTypeDefContent in search results', () => {
|
||||
const results = engine.searchByName('AI Agent');
|
||||
const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent');
|
||||
expect(agentResult).toBeDefined();
|
||||
// Result type has no extraTypeDefContent field; assert it never leaks in
|
||||
// via untyped assignment either.
|
||||
expect(agentResult).not.toHaveProperty('extraTypeDefContent');
|
||||
expect(JSON.stringify(agentResult)).not.toContain('<patterns>');
|
||||
expect(JSON.stringify(agentResult)).not.toContain('basic');
|
||||
// The formatted XML the LLM actually sees must not contain the example.
|
||||
const xml = engine.formatResult(agentResult!);
|
||||
expect(xml).not.toContain('<patterns>');
|
||||
expect(xml).not.toContain('const agent = node');
|
||||
});
|
||||
|
||||
it('should include subnode requirements when present', () => {
|
||||
const results = engine.searchByName('AI Agent');
|
||||
const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent');
|
||||
|
||||
@@ -67,6 +67,17 @@ export interface SearchableNodeType {
|
||||
builderHint?: {
|
||||
message?: 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[]>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
* - createSandboxBuilderAgentPrompt(): Sandbox-based builder with real files + tsc
|
||||
*/
|
||||
|
||||
import {
|
||||
AI_TOOL_PATTERNS,
|
||||
CONNECTION_CHANGING_PARAMETERS,
|
||||
BASELINE_FLOW_CONTROL,
|
||||
} from '@n8n/workflow-sdk/prompts/node-selection';
|
||||
import {
|
||||
EXPRESSION_REFERENCE,
|
||||
ADDITIONAL_FUNCTIONS,
|
||||
@@ -60,219 +55,12 @@ const NODE_CONFIGURATION_SAFETY_RULES = `## Node Configuration Safety Rules
|
||||
- Use live \`nodes(action="explore-resources")\` for resource locator, list, and model fields when credentials are available.
|
||||
- If a configuration is unclear after reading the definition, ask for clarification or use placeholders — do not guess.`;
|
||||
|
||||
// The AI Agent subnode example uses `newCredential()` in both modes. In sandbox
|
||||
// mode the submit runner preserves unresolved credential slots for
|
||||
// `submit-workflow`, so the same outlet works there too.
|
||||
function buildBuilderSpecificPatterns(): string {
|
||||
const openAiCredExample = "newCredential('OpenAI')";
|
||||
return `## Critical Patterns (Common Mistakes)
|
||||
// Node-specific configuration examples used to live here. They have moved
|
||||
// onto the nodes themselves as `@builderHint` annotations and `<patterns>...</patterns>`
|
||||
// blocks in the generated `.d.ts` — fetch them on-demand via `nodes(action="type-definition")`.
|
||||
const BUILDER_SPECIFIC_PATTERNS = `## Critical Patterns (Common Mistakes)
|
||||
|
||||
**Pay attention to @builderHint annotations in search results and type definitions** — these provide critical guidance on how to correctly configure node parameters. Write them out as notes when reviewing — they prevent common configuration mistakes.
|
||||
|
||||
### Self-check: conditional nodes and routing
|
||||
|
||||
After writing any workflow with IF, Switch, or Filter nodes, verify:
|
||||
1. **Every \`conditions\` object has \`options\`, \`conditions\` array, and \`combinator\`** — missing any of these crashes the node at runtime.
|
||||
2. **Switch uses \`rules.values\`** (not \`rules.rules\`) — the wrong key crashes during workflow loading.
|
||||
3. **Each branch reaches the correct destination** — trace the data flow from the condition through \`.onTrue()\`/\`.onFalse()\`/\`.onCase()\` to the target node. Verify the routing matches the user's requirements.
|
||||
4. **Condition expressions reference the right fields** — check that \`leftValue\` expressions use fields that actually exist in the upstream node's output.
|
||||
5. **Merge nodes use the correct mode** — \`append\` to concatenate items from branches, \`combineBySql\` or \`combineByPosition\` only when matching items across inputs. Wrong mode silently drops or duplicates data.
|
||||
|
||||
### AI Agent with Subnodes — use factory functions in subnodes config
|
||||
\`\`\`javascript
|
||||
const chatTrigger = trigger({
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Chat Trigger',
|
||||
parameters: { public: false },
|
||||
output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }]
|
||||
}
|
||||
});
|
||||
|
||||
const model = languageModel({
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'OpenAI Chat Model',
|
||||
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
|
||||
credentials: { openAiApi: ${openAiCredExample} }
|
||||
}
|
||||
});
|
||||
|
||||
const parser = outputParser({
|
||||
type: '@n8n/n8n-nodes-langchain.outputParserStructured',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Output Parser',
|
||||
parameters: {
|
||||
schemaType: 'fromJson',
|
||||
jsonSchemaExample: '{ "score": 75, "tier": "hot" }'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const memoryNode = memory({
|
||||
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Conversation Memory',
|
||||
parameters: {
|
||||
sessionIdType: 'customKey',
|
||||
sessionKey: nodeJson(chatTrigger, 'sessionId'),
|
||||
contextWindowLength: 10
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const agent = node({
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
version: 3.1,
|
||||
config: {
|
||||
name: 'AI Agent',
|
||||
parameters: {
|
||||
promptType: 'define',
|
||||
text: '={{ $json.prompt }}',
|
||||
hasOutputParser: true,
|
||||
options: { systemMessage: 'You are an expert...' }
|
||||
},
|
||||
subnodes: { model: model, memory: memoryNode, outputParser: parser }
|
||||
}
|
||||
});
|
||||
\`\`\`
|
||||
WRONG: \`.to(agent, { connectionType: 'ai_languageModel' })\` — subnodes MUST be in the config object.
|
||||
For values inside AI subnodes, use explicit references such as \`nodeJson(triggerNode, 'sessionId')\` instead of \`$json.sessionId\`. For Chat Trigger memory specifically, \`sessionIdType: 'fromInput'\` is also valid.
|
||||
|
||||
### Code Node
|
||||
\`\`\`javascript
|
||||
const codeNode = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Process Data',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: \\\`
|
||||
const items = $input.all();
|
||||
return items.map(item => ({
|
||||
json: { ...item.json, processed: true }
|
||||
}));
|
||||
\\\`.trim()
|
||||
}
|
||||
}
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Data Table (built-in n8n storage)
|
||||
\`\`\`javascript
|
||||
const storeData = node({
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
version: 1.1,
|
||||
config: {
|
||||
name: 'Store Data',
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'insert',
|
||||
dataTableId: { __rl: true, mode: 'name', value: 'my-table' },
|
||||
columns: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
name: '={{ $json.name }}',
|
||||
email: '={{ $json.email }}'
|
||||
},
|
||||
schema: [
|
||||
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
|
||||
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
**Data Table rules**
|
||||
- Row IDs are auto-generated by Data Tables. Do NOT create a custom \`id\` column and do NOT seed an \`id\` value on insert.
|
||||
- To fetch many rows, use \`operation: 'get'\` with \`returnAll: true\`. Do NOT invent \`getAll\`.
|
||||
- When filtering rows for update/delete, it is valid to match on the built-in row \`id\`, but that is not part of the user-defined table schema.
|
||||
|
||||
### Google Sheets — Column Mapping
|
||||
The \`columns\` parameter requires a schema object, never a string:
|
||||
\`\`\`javascript
|
||||
// autoMapInputData — maps $json fields to sheet columns automatically
|
||||
columns: {
|
||||
mappingMode: 'autoMapInputData',
|
||||
value: {},
|
||||
schema: [
|
||||
{ id: 'Name', displayName: 'Name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
|
||||
{ id: 'Email', displayName: 'Email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: false },
|
||||
]
|
||||
}
|
||||
|
||||
// defineBelow — explicit expression mapping
|
||||
columns: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: { name: '={{ $json.name }}', email: '={{ $json.email }}' },
|
||||
schema: [
|
||||
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
|
||||
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
WRONG: \`columns: 'autoMapInputData'\` — this is a string, not a schema object. Will fail validation.
|
||||
|
||||
### Parallel Branches + Merge
|
||||
When multiple paths must converge, include the full downstream chain in EACH branch.
|
||||
There is NO fan-in primitive — shared nodes must be duplicated or use sub-workflows.
|
||||
|
||||
### Batch Processing — splitInBatches with loop
|
||||
\`\`\`javascript
|
||||
const batch = node({
|
||||
type: 'n8n-nodes-base.splitInBatches',
|
||||
version: 3,
|
||||
config: { name: 'Batch', parameters: { batchSize: 50 } }
|
||||
});
|
||||
// Connect: trigger -> batch -> processNode -> batch (loop back)
|
||||
// The batch node automatically outputs to "done" when all items are processed.
|
||||
\`\`\`
|
||||
|
||||
### Multiple Triggers
|
||||
Independent entry points can feed into shared downstream nodes. Each trigger starts its own branch:
|
||||
\`\`\`javascript
|
||||
export default workflow('id', 'name')
|
||||
.add(webhookTrigger).to(processNode).to(storeNode)
|
||||
.add(scheduleTrigger).to(processNode);
|
||||
\`\`\`
|
||||
|
||||
### Google Sheets — documentId and sheetName (RLC fields)
|
||||
|
||||
These are Resource Locator fields that require the \`__rl\` object format:
|
||||
\`\`\`typescript
|
||||
// CORRECT — RLC object with discovered ID
|
||||
documentId: { __rl: true, mode: 'id', value: '1abc123...' },
|
||||
sheetName: { __rl: true, mode: 'name', value: 'Sheet1' },
|
||||
|
||||
// CORRECT — RLC with name-based lookup
|
||||
documentId: { __rl: true, mode: 'name', value: 'Sales Pipeline' },
|
||||
|
||||
// WRONG — plain string
|
||||
documentId: 'YOUR_SPREADSHEET_ID', // Not an RLC object
|
||||
|
||||
// WRONG — expr() wrapper
|
||||
documentId: expr('{{ "spreadsheetId" }}'), // RLC fields don't use expressions
|
||||
\`\`\`
|
||||
Always use the IDs from \`nodes(action="explore-resources")\` results inside the RLC \`value\` field.
|
||||
|
||||
### AI Tool Connection Patterns
|
||||
${AI_TOOL_PATTERNS}
|
||||
|
||||
### Connection-Changing Parameters
|
||||
${CONNECTION_CHANGING_PARAMETERS}
|
||||
|
||||
### Baseline Flow Control Nodes
|
||||
${BASELINE_FLOW_CONTROL}`;
|
||||
}
|
||||
|
||||
const BUILDER_SPECIFIC_PATTERNS = buildBuilderSpecificPatterns();
|
||||
**Pay attention to @builderHint annotations in search results and type definitions** — they contain node-specific configuration rules and code examples. Read them carefully when configuring any node — they prevent common mistakes.`;
|
||||
|
||||
// ── Composed SDK rules from shared + local sources ───────────────────────────
|
||||
|
||||
@@ -340,11 +128,12 @@ ${PLACEHOLDERS_RULE}
|
||||
## Mandatory Process
|
||||
1. **Research**: If the workflow fits a known category (notification, chatbot, scheduling, data_transformation, etc.), call \`nodes(action="suggested")\` first for curated recommendations. Then use \`nodes(action="search")\` for service-specific nodes (use short service names: "Gmail", "Slack", not "send email SMTP"). The results include \`discriminators\` (available resources and operations) for nodes that need them. Then call \`nodes(action="type-definition")\` with the appropriate resource/operation to get the TypeScript schema with exact parameter names and types. **Pay attention to @builderHint annotations** in search results and type definitions — they prevent common configuration mistakes.
|
||||
2. **Build**: Write TypeScript SDK code and call \`build-workflow\`. Follow the SDK patterns below exactly.
|
||||
3. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code.
|
||||
4. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace.
|
||||
5. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message.
|
||||
3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
4. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code.
|
||||
5. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace.
|
||||
6. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message.
|
||||
|
||||
Do NOT produce visible output until step 5. All reasoning happens internally.
|
||||
Do NOT produce visible output until step 6. All reasoning happens internally.
|
||||
|
||||
## Credential Rules (tool mode)
|
||||
- Use \`newCredential('Credential Name', 'credential-id')\` only when the user selected a specific existing credential or the workflow already has one.
|
||||
@@ -448,8 +237,8 @@ const fetchWeather = node({
|
||||
name: 'Fetch Weather',
|
||||
parameters: {
|
||||
locationSelection: 'cityName',
|
||||
cityName: '={{ $json.city }}',
|
||||
format: '={{ $json.units }}'
|
||||
cityName: expr('{{ $json.city }}'),
|
||||
format: expr('{{ $json.units }}')
|
||||
},
|
||||
credentials: { openWeatherMapApi: { id: 'credId', name: 'OpenWeatherMap account' } }
|
||||
}
|
||||
@@ -617,18 +406,20 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`).
|
||||
|
||||
5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`.
|
||||
|
||||
6. **Validate with tsc**: Run the TypeScript compiler for real type checking:
|
||||
6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
|
||||
7. **Validate with tsc**: Run the TypeScript compiler for real type checking:
|
||||
\`\`\`
|
||||
execute_command: cd ~/workspace && npx tsc --noEmit 2>&1
|
||||
\`\`\`
|
||||
Fix any errors using \`edit_file\` (with absolute path) to update the code, then re-run tsc. Iterate until clean.
|
||||
**Important**: If tsc reports errors you cannot resolve after 2 attempts, skip tsc and proceed to submit-workflow. The submit tool has its own validation.
|
||||
|
||||
7. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n.
|
||||
8. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n.
|
||||
|
||||
8. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error.
|
||||
9. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error.
|
||||
|
||||
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
|
||||
10. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
|
||||
|
||||
### For complex workflows (5+ nodes, multiple integrations):
|
||||
|
||||
@@ -644,8 +435,9 @@ Follow the **Compositional Workflow Pattern** above. The process becomes:
|
||||
c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`.
|
||||
d. Fix if needed (max 2 submission fix attempts per chunk).
|
||||
6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID.
|
||||
7. **Submit** the main workflow.
|
||||
8. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
|
||||
7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
8. **Submit** the main workflow.
|
||||
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
|
||||
|
||||
Do NOT produce visible output until the final step. All reasoning happens internally.
|
||||
|
||||
|
||||
@@ -52,6 +52,58 @@ export class GuardrailsV2 implements INodeType {
|
||||
},
|
||||
searchHint:
|
||||
'Classify operation has two outputs: output 0 (Pass) for items that passed all guardrail checks, output 1 (Fail) for items that failed. Use .output(index).to() to connect from a specific output. @example guardrails.output(0).to(passNode) and guardrails.output(1).to(failNode). Sanitize operation has only one output.',
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['classify'],
|
||||
},
|
||||
},
|
||||
content: `<patterns>
|
||||
<pattern title="Guardrails classify with separate Pass and Fail outputs">
|
||||
const model = languageModel({
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'OpenAI Chat Model',
|
||||
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
|
||||
credentials: { openAiApi: { id: 'credId', name: 'OpenAI account' } }
|
||||
}
|
||||
});
|
||||
|
||||
const guardrailsCheck = node({
|
||||
type: '@n8n/n8n-nodes-langchain.guardrails',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Guardrails',
|
||||
parameters: {
|
||||
operation: 'classify',
|
||||
text: expr('{{ $json.input }}'),
|
||||
guardrails: { jailbreak: { value: { threshold: 0.7 } } }
|
||||
},
|
||||
subnodes: { model }
|
||||
}
|
||||
});
|
||||
|
||||
const passHandler = node({
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: { name: 'Handle Pass', parameters: {} }
|
||||
});
|
||||
|
||||
const failHandler = node({
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: { name: 'Handle Fail', parameters: {} }
|
||||
});
|
||||
|
||||
// output 0 = Pass, output 1 = Fail
|
||||
guardrailsCheck.output(0).to(passHandler);
|
||||
guardrailsCheck.output(1).to(failHandler);
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export class Agent extends VersionedNodeType {
|
||||
},
|
||||
defaultVersion: 3.1,
|
||||
builderHint: {
|
||||
searchHint:
|
||||
"Wire model/memory/tools/outputParser via the SDK `subnodes` config object using factory functions (`languageModel()`, `memory()`, `tool()`, `outputParser()`). Inside subnodes, reference upstream data with `nodeJson(triggerNode, 'path')`, not `$json` — subnodes do not share the main predecessor's item context.",
|
||||
relatedNodes: [
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.aggregate',
|
||||
@@ -50,6 +52,73 @@ export class Agent extends VersionedNodeType {
|
||||
'Required for conversational workflows - connect memory to every agent that needs to recall previous messages in the conversation',
|
||||
},
|
||||
],
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
content: `<patterns>
|
||||
<pattern title="Agent with model, memory, structured output parser">
|
||||
const chatTrigger = trigger({
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Chat Trigger',
|
||||
parameters: { public: false },
|
||||
output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }]
|
||||
}
|
||||
});
|
||||
|
||||
const model = languageModel({
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'OpenAI Chat Model',
|
||||
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
|
||||
credentials: { openAiApi: { id: 'credId', name: 'OpenAI account' } }
|
||||
}
|
||||
});
|
||||
|
||||
const parser = outputParser({
|
||||
type: '@n8n/n8n-nodes-langchain.outputParserStructured',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Output Parser',
|
||||
parameters: {
|
||||
schemaType: 'fromJson',
|
||||
jsonSchemaExample: '{ "score": 75, "tier": "hot" }'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const memoryNode = memory({
|
||||
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Conversation Memory',
|
||||
parameters: {
|
||||
sessionIdType: 'customKey',
|
||||
sessionKey: nodeJson(chatTrigger, 'sessionId'),
|
||||
contextWindowLength: 10
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const agent = node({
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
version: 3.1,
|
||||
config: {
|
||||
name: 'AI Agent',
|
||||
parameters: {
|
||||
promptType: 'define',
|
||||
text: expr('{{ $json.prompt }}'),
|
||||
hasOutputParser: true,
|
||||
options: { systemMessage: 'You are an expert...' }
|
||||
},
|
||||
subnodes: { model, memory: memoryNode, outputParser: parser }
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ export class AgentToolV3 implements INodeType {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
'Set to `true` when you need structured JSON output. The agent then requires an `outputParser` entry in its `subnodes` config (typically an `outputParserStructured` node defined via the `outputParser({...})` SDK factory). With `hasOutputParser: false` the agent returns a plain string in `$json.output`.',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
|
||||
|
||||
@@ -98,6 +98,10 @@ export class AgentV3 implements INodeType {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
'Set to `true` when you need structured JSON output. The agent then requires an `outputParser` entry in its `subnodes` config (typically an `outputParserStructured` node defined via the `outputParser({...})` SDK factory). With `hasOutputParser: false` the agent returns a plain string in `$json.output`.',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
|
||||
|
||||
@@ -18,9 +18,18 @@ import type * as GenerateTypesModule from '../generate-types/generate-types';
|
||||
// Type Definitions (Expected interfaces from the implementation)
|
||||
// =============================================================================
|
||||
|
||||
interface BuilderHintVariation {
|
||||
content: string;
|
||||
displayOptions?: {
|
||||
show?: Record<string, unknown[]>;
|
||||
hide?: Record<string, unknown[]>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ParameterBuilderHint {
|
||||
propertyHint: string;
|
||||
placeholderSupported?: boolean;
|
||||
extraTypeDefContent?: BuilderHintVariation[];
|
||||
}
|
||||
|
||||
interface NestedOption {
|
||||
@@ -76,6 +85,7 @@ interface NodeTypeDescription {
|
||||
hidden?: boolean;
|
||||
schemaPath?: string;
|
||||
builderHint?: {
|
||||
message?: string;
|
||||
inputs?: Record<
|
||||
string,
|
||||
{
|
||||
@@ -86,6 +96,7 @@ interface NodeTypeDescription {
|
||||
};
|
||||
}
|
||||
>;
|
||||
extraTypeDefContent?: BuilderHintVariation[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1393,6 +1404,156 @@ describe('generate-types', () => {
|
||||
expect(result).not.toContain('GmailV21Params');
|
||||
});
|
||||
|
||||
it('should route node-level extraTypeDefContent variations into matching narrowed types only', () => {
|
||||
const vectorStoreLikeNode: NodeTypeDescription & { builderHint?: unknown } = {
|
||||
name: 'n8n-nodes-test.vectorStoreLike',
|
||||
displayName: 'Vector Store Like',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
builderHint: {
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
displayOptions: { show: { mode: ['insert'] } },
|
||||
content: '<patterns>\n<pattern>insert example</pattern>\n</patterns>',
|
||||
},
|
||||
{
|
||||
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
|
||||
content: '<patterns>\n<pattern>RAG example</pattern>\n</patterns>',
|
||||
},
|
||||
],
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Insert', value: 'insert' },
|
||||
{ name: 'Retrieve as Tool', value: 'retrieve-as-tool' },
|
||||
],
|
||||
default: 'insert',
|
||||
},
|
||||
{
|
||||
displayName: 'Insert Field',
|
||||
name: 'insertField',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: { show: { mode: ['insert'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Tool Description',
|
||||
name: 'toolDescription',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = generateTypes.generateDiscriminatedUnion(vectorStoreLikeNode);
|
||||
|
||||
// Each variation must land in its own narrowed type body — no cross-bleed.
|
||||
const sections = result.split(/export type /);
|
||||
const insertSection = sections.find((s) => s.startsWith('VectorStoreLikeInsertParams'));
|
||||
const retrieveSection = sections.find((s) =>
|
||||
s.startsWith('VectorStoreLikeRetrieveAsToolParams'),
|
||||
);
|
||||
expect(insertSection).toBeDefined();
|
||||
expect(retrieveSection).toBeDefined();
|
||||
|
||||
expect(insertSection!).toContain('<pattern>insert example</pattern>');
|
||||
expect(insertSection!).not.toContain('RAG example');
|
||||
|
||||
expect(retrieveSection!).toContain('<pattern>RAG example</pattern>');
|
||||
expect(retrieveSection!).not.toContain('insert example');
|
||||
});
|
||||
|
||||
it('should not duplicate an unconditional node-level variation across narrowed types (file header only)', () => {
|
||||
const node: NodeTypeDescription & { builderHint?: unknown } = {
|
||||
name: 'n8n-nodes-test.unconditional',
|
||||
displayName: 'Unconditional',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
builderHint: {
|
||||
extraTypeDefContent: [{ content: 'unconditional only' }],
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Insert', value: 'insert' },
|
||||
{ name: 'Retrieve', value: 'retrieve' },
|
||||
],
|
||||
default: 'insert',
|
||||
},
|
||||
{
|
||||
displayName: 'Insert Field',
|
||||
name: 'insertField',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: { show: { mode: ['insert'] } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Unconditional content does NOT appear inside narrowed type bodies —
|
||||
// it's reserved for the file-level node header.
|
||||
const result = generateTypes.generateDiscriminatedUnion(node);
|
||||
expect(result).not.toContain('unconditional only');
|
||||
|
||||
// File header emits it exactly once.
|
||||
const header = generateTypes.generateNodeJSDoc(node);
|
||||
expect(header).toContain('unconditional only');
|
||||
expect(header.match(/unconditional only/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should skip variations whose displayOptions do not match the combo', () => {
|
||||
const node: NodeTypeDescription & { builderHint?: unknown } = {
|
||||
name: 'n8n-nodes-test.partialMatch',
|
||||
displayName: 'Partial Match',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
builderHint: {
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
displayOptions: { show: { mode: ['unknownMode'] } },
|
||||
content: 'should NOT appear',
|
||||
},
|
||||
],
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Insert', value: 'insert' },
|
||||
{ name: 'Retrieve', value: 'retrieve' },
|
||||
],
|
||||
default: 'insert',
|
||||
},
|
||||
{
|
||||
displayName: 'Insert Field',
|
||||
name: 'insertField',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: { show: { mode: ['insert'] } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = generateTypes.generateDiscriminatedUnion(node);
|
||||
expect(result).not.toContain('should NOT appear');
|
||||
});
|
||||
|
||||
it('should generate simple interface for HTTP Request (no discriminators)', () => {
|
||||
const result = generateTypes.generateDiscriminatedUnion(mockHttpRequestNode);
|
||||
|
||||
@@ -1776,6 +1937,77 @@ describe('generate-types', () => {
|
||||
expect(result).toContain('@builderHint');
|
||||
expect(result).toContain('<a href=');
|
||||
});
|
||||
|
||||
it('should include unconditional extraTypeDefContent variation below @builderHint, preserving line breaks and tags verbatim', () => {
|
||||
const prop: NodeProperty = {
|
||||
name: 'columns',
|
||||
displayName: 'Columns',
|
||||
type: 'resourceMapper',
|
||||
description: 'Column mapping',
|
||||
builderHint: {
|
||||
propertyHint: 'Pass the full resourceMapper object',
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
content:
|
||||
'<patterns>\n<pattern title="autoMap">\ncolumns: { mappingMode: \'autoMapInputData\' }\n</pattern>\n</patterns>',
|
||||
},
|
||||
],
|
||||
},
|
||||
default: {},
|
||||
};
|
||||
const result = generateTypes.generatePropertyJSDoc(prop);
|
||||
expect(result).toContain('@builderHint Pass the full resourceMapper object');
|
||||
expect(result).toContain(' * <patterns>');
|
||||
expect(result).toContain(' * <pattern title="autoMap">');
|
||||
expect(result).toContain(" * columns: { mappingMode: 'autoMapInputData' }");
|
||||
expect(result).toContain(' * </pattern>');
|
||||
expect(result).toContain(' * </patterns>');
|
||||
// Angle brackets in variation content must NOT be HTML-escaped — the LLM
|
||||
// must see the tags verbatim so it can use them as structural cues.
|
||||
expect(result).not.toContain('<patterns>');
|
||||
expect(result).not.toContain('<pattern title=');
|
||||
});
|
||||
|
||||
it('should escape closing JSDoc sequences inside variation content', () => {
|
||||
const prop: NodeProperty = {
|
||||
name: 'foo',
|
||||
displayName: 'Foo',
|
||||
type: 'string',
|
||||
description: 'Foo',
|
||||
builderHint: {
|
||||
propertyHint: 'msg',
|
||||
extraTypeDefContent: [{ content: 'block end */ inside example' }],
|
||||
},
|
||||
default: '',
|
||||
};
|
||||
const result = generateTypes.generatePropertyJSDoc(prop);
|
||||
// The literal "*/" would terminate the JSDoc block early; it must be escaped.
|
||||
expect(result).not.toContain('block end */ inside');
|
||||
expect(result).toContain('block end *\\/ inside');
|
||||
});
|
||||
|
||||
it('should skip param-level variations whose displayOptions cannot be evaluated at file/property scope', () => {
|
||||
// Param-level emission (generatePropertyJSDoc, generateNestedPropertyJSDoc) has
|
||||
// no discriminator combo, so any gated variation is dropped — those belong on the
|
||||
// node-level builderHint where the codegen can route them per narrowed type.
|
||||
const prop: NodeProperty = {
|
||||
name: 'foo',
|
||||
displayName: 'Foo',
|
||||
type: 'string',
|
||||
description: 'Foo',
|
||||
builderHint: {
|
||||
propertyHint: 'msg',
|
||||
extraTypeDefContent: [
|
||||
{ displayOptions: { show: { mode: ['insert'] } }, content: 'gated content' },
|
||||
{ content: 'always shown' },
|
||||
],
|
||||
},
|
||||
default: '',
|
||||
};
|
||||
const result = generateTypes.generatePropertyJSDoc(prop);
|
||||
expect(result).toContain('always shown');
|
||||
expect(result).not.toContain('gated content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateNodeJSDoc', () => {
|
||||
@@ -1789,6 +2021,37 @@ describe('generate-types', () => {
|
||||
const result = generateTypes.generateNodeJSDoc(mockGmailNode);
|
||||
expect(result).toContain('Node Types');
|
||||
});
|
||||
|
||||
it('should emit node-level @builderHint searchHint at the file header', () => {
|
||||
const node = {
|
||||
...mockGmailNode,
|
||||
builderHint: {
|
||||
searchHint: 'AI Agent — wire subnodes via the config object',
|
||||
},
|
||||
};
|
||||
const result = generateTypes.generateNodeJSDoc(node);
|
||||
expect(result).toContain('@builderHint AI Agent — wire subnodes via the config object');
|
||||
});
|
||||
|
||||
it('should emit unconditional extraTypeDefContent variations at the file header but skip gated ones', () => {
|
||||
const node = {
|
||||
...mockGmailNode,
|
||||
builderHint: {
|
||||
extraTypeDefContent: [
|
||||
{ content: '<patterns>\n<pattern>always</pattern>\n</patterns>' },
|
||||
{
|
||||
displayOptions: { show: { mode: ['insert'] } },
|
||||
content: '<patterns>\n<pattern>insert-only</pattern>\n</patterns>',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = generateTypes.generateNodeJSDoc(node);
|
||||
// Unconditional variation lands at file header.
|
||||
expect(result).toContain(' * <pattern>always</pattern>');
|
||||
// Gated variation does NOT — it's emitted per-combo via emitNodeHintForCombo.
|
||||
expect(result).not.toContain('insert-only');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -250,6 +250,20 @@ const AI_TYPE_TO_SUBNODE_FIELD: Record<
|
||||
// Type Definitions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* One variation of `extraTypeDefContent`, optionally gated by `displayOptions`.
|
||||
* Variations with `displayOptions` are emitted only in narrowed discriminator
|
||||
* types (e.g. per-mode or per-resource/operation files) whose combo matches.
|
||||
* Variations without `displayOptions` are emitted unconditionally.
|
||||
*/
|
||||
export interface BuilderHintVariation {
|
||||
content: string;
|
||||
displayOptions?: {
|
||||
show?: Record<string, unknown[]>;
|
||||
hide?: Record<string, unknown[]>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParameterBuilderHint {
|
||||
propertyHint: string;
|
||||
placeholderSupported?: boolean;
|
||||
@@ -350,6 +364,117 @@ export interface JsonSchema {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JSDoc emission helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Emit `@builderHint` JSDoc plus optional multi-line `extraTypeDefContent` lines.
|
||||
*
|
||||
* `message` is HTML-escaped (`<` / `>` → entities) because it round-trips through
|
||||
* the search engine's `<builder_hint>...</builder_hint>` XML envelope, where bare
|
||||
* angle brackets would corrupt the wrapping tag.
|
||||
*
|
||||
* `extraTypeDefContent` does NOT escape angle brackets — author-written tags such
|
||||
* as `<patterns>` must round-trip into the `.d.ts` verbatim so the LLM sees them
|
||||
* as structural cues. Only `*\/` is escaped to keep the JSDoc block well-formed.
|
||||
*/
|
||||
/**
|
||||
* Determines whether a variation should be emitted in the current scope.
|
||||
*
|
||||
* The two scopes are mutually exclusive so unconditional variations are
|
||||
* NOT duplicated across narrowed types:
|
||||
*
|
||||
* - File-level header (no `combo`): emit ONLY unconditional variations
|
||||
* (those with no `displayOptions`). They appear once at the top of the
|
||||
* generated `.d.ts` and cover every narrowed type.
|
||||
*
|
||||
* - Narrowed config block (per `combo`): emit ONLY gated variations
|
||||
* whose `displayOptions` match the combo. Unconditional variations are
|
||||
* skipped here — they were already emitted at the file header.
|
||||
*/
|
||||
function variationApplies(
|
||||
variation: BuilderHintVariation,
|
||||
combo: DiscriminatorCombination | undefined,
|
||||
): boolean {
|
||||
const opts = variation.displayOptions;
|
||||
|
||||
// File-level scope: only unconditional variations.
|
||||
if (!combo) return !opts;
|
||||
|
||||
// Narrowed scope: only gated variations whose displayOptions match.
|
||||
if (!opts) return false;
|
||||
|
||||
if (opts.show) {
|
||||
for (const [key, conditions] of Object.entries(opts.show)) {
|
||||
const value = combo[key];
|
||||
if (value === undefined) return false;
|
||||
if (!checkConditions(conditions, [value])) return false;
|
||||
}
|
||||
}
|
||||
if (opts.hide) {
|
||||
for (const [key, conditions] of Object.entries(opts.hide)) {
|
||||
const value = combo[key];
|
||||
if (value === undefined) continue;
|
||||
if (checkConditions(conditions, [value])) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function emitBuilderHint(
|
||||
lines: string[],
|
||||
indent: string,
|
||||
hint: { propertyHint?: string; extraTypeDefContent?: BuilderHintVariation[] },
|
||||
combo?: DiscriminatorCombination,
|
||||
): void {
|
||||
if (hint.propertyHint) {
|
||||
const safePropertyHint = hint.propertyHint
|
||||
.replace(/\*\//g, '*\\/')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
lines.push(`${indent} * @builderHint ${safePropertyHint}`);
|
||||
}
|
||||
|
||||
if (!hint.extraTypeDefContent) return;
|
||||
|
||||
for (const variation of hint.extraTypeDefContent) {
|
||||
if (!variationApplies(variation, combo)) continue;
|
||||
const safe = variation.content.replace(/\*\//g, '*\\/');
|
||||
for (const line of safe.split('\n')) {
|
||||
lines.push(`${indent} * ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `builderHint` is an extended n8n property not part of the upstream
|
||||
* `NodeTypeDescription`. Centralized cast keeps the rest of the file clean.
|
||||
*/
|
||||
function getNodeBuilderHint(node: NodeTypeDescription): NodeBuilderHint | undefined {
|
||||
return (node as NodeTypeDescription & { builderHint?: NodeBuilderHint }).builderHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a JSDoc block for the node-level builderHint scoped to a single
|
||||
* discriminator combination — only variations whose `displayOptions` match the
|
||||
* combo are rendered. The `propertyHint` is intentionally not re-emitted per combo
|
||||
* (it already lands in the file-level node header).
|
||||
*/
|
||||
function emitNodeHintForCombo(
|
||||
lines: string[],
|
||||
node: NodeTypeDescription,
|
||||
combo: DiscriminatorCombination,
|
||||
): void {
|
||||
const hint = getNodeBuilderHint(node);
|
||||
if (!hint?.extraTypeDefContent?.some((v) => variationApplies(v, combo))) return;
|
||||
|
||||
const hintLines: string[] = [`${INDENT}/**`];
|
||||
emitBuilderHint(hintLines, INDENT, { extraTypeDefContent: hint.extraTypeDefContent }, combo);
|
||||
hintLines.push(`${INDENT} */`);
|
||||
lines.push(...hintLines);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schema Discovery & JSON Schema to TypeScript Conversion
|
||||
// =============================================================================
|
||||
@@ -883,11 +1008,7 @@ function generateNestedPropertyJSDoc(
|
||||
|
||||
// Builder hint - guidance for AI/workflow builders
|
||||
if (prop.builderHint) {
|
||||
const safeBuilderHint = prop.builderHint.propertyHint
|
||||
.replace(/\*\//g, '*\\/')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
lines.push(`${indent} * @builderHint ${safeBuilderHint}`);
|
||||
emitBuilderHint(lines, indent, prop.builderHint);
|
||||
}
|
||||
|
||||
// Placeholder support flag — signals to the builder agent (and the runtime
|
||||
@@ -1055,14 +1176,10 @@ function generateFixedCollectionType(
|
||||
groupJsDocLines.push(`${INDENT.repeat(2)}/** ${desc}`);
|
||||
}
|
||||
if (group.builderHint) {
|
||||
const safeBuilderHint = group.builderHint.propertyHint
|
||||
.replace(/\*\//g, '*\\/')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
if (groupJsDocLines.length === 0) {
|
||||
groupJsDocLines.push(`${INDENT.repeat(2)}/**`);
|
||||
}
|
||||
groupJsDocLines.push(`${INDENT.repeat(2)} * @builderHint ${safeBuilderHint}`);
|
||||
emitBuilderHint(groupJsDocLines, INDENT.repeat(2), group.builderHint);
|
||||
}
|
||||
if (isMultipleValues && hasMinRequired) {
|
||||
if (groupJsDocLines.length === 0) {
|
||||
@@ -1859,7 +1976,9 @@ export function generateDiscriminatedUnion(node: NodeTypeDescription): string {
|
||||
|
||||
lines.push(`export type ${configName} = {`);
|
||||
|
||||
// Add discriminator fields
|
||||
emitNodeHintForCombo(lines, node, combo);
|
||||
|
||||
// Discriminator literal fields for this combo.
|
||||
for (const [key, value] of Object.entries(combo)) {
|
||||
if (value !== undefined) {
|
||||
lines.push(`${INDENT}${key}: '${value}';`);
|
||||
@@ -1914,11 +2033,7 @@ export function generatePropertyJSDoc(
|
||||
|
||||
// Builder hint - guidance for AI/workflow builders
|
||||
if (prop.builderHint) {
|
||||
const safeBuilderHint = prop.builderHint.propertyHint
|
||||
.replace(/\*\//g, '*\\/')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
lines.push(` * @builderHint ${safeBuilderHint}`);
|
||||
emitBuilderHint(lines, '', prop.builderHint);
|
||||
}
|
||||
|
||||
// Placeholder support flag — signals to the builder agent (and the runtime
|
||||
@@ -2025,6 +2140,18 @@ export function generateNodeJSDoc(node: NodeTypeDescription): string {
|
||||
lines.push(` * @subnodeType ${subnodeType}`);
|
||||
}
|
||||
|
||||
// Node-level builder hint — searchHint and unconditional extraTypeDefContent.
|
||||
// `relatedNodes` and `inputs` are consumed elsewhere (search engine, subnode
|
||||
// extraction). Variations with `displayOptions` are skipped here — they're
|
||||
// emitted per-combo in narrowed config types via `emitNodeHintForCombo`.
|
||||
const nodeHint = getNodeBuilderHint(node);
|
||||
if (nodeHint && (nodeHint.searchHint || nodeHint.extraTypeDefContent?.length)) {
|
||||
emitBuilderHint(lines, '', {
|
||||
propertyHint: nodeHint.searchHint,
|
||||
extraTypeDefContent: nodeHint.extraTypeDefContent,
|
||||
});
|
||||
}
|
||||
|
||||
lines.push(' */');
|
||||
|
||||
return lines.join('\n');
|
||||
@@ -2533,7 +2660,9 @@ export function generateDiscriminatorFile(
|
||||
}
|
||||
lines.push(`export type ${configName} = {`);
|
||||
|
||||
// Add discriminator fields
|
||||
emitNodeHintForCombo(lines, node, combo);
|
||||
|
||||
// Discriminator literal fields for this combo.
|
||||
for (const [key, value] of Object.entries(combo)) {
|
||||
if (value !== undefined) {
|
||||
lines.push(`${INDENT}${key}: '${value}';`);
|
||||
@@ -3398,7 +3527,9 @@ function generateDiscriminatedUnionForEntry(
|
||||
|
||||
lines.push(`export type ${configName} = {`);
|
||||
|
||||
// Add discriminator fields
|
||||
emitNodeHintForCombo(lines, node, combo);
|
||||
|
||||
// Discriminator literal fields for this combo.
|
||||
for (const [key, value] of Object.entries(combo)) {
|
||||
if (value !== undefined) {
|
||||
lines.push(`${INDENT}${key}: '${value}';`);
|
||||
@@ -3568,7 +3699,18 @@ interface BuilderHintInput {
|
||||
}
|
||||
|
||||
interface NodeBuilderHint {
|
||||
searchHint?: string;
|
||||
relatedNodes?: Array<{ nodeType: string; relationHint: string }>;
|
||||
inputs?: Record<string, BuilderHintInput>;
|
||||
/**
|
||||
* Multi-line content (typically code examples wrapped in `<patterns>...</patterns>`)
|
||||
* emitted into the generated `.d.ts` but NOT surfaced in
|
||||
* `nodes(action="search")` results. Each variation may carry `displayOptions`
|
||||
* so per-mode / per-resource / per-operation examples land only in their
|
||||
* corresponding narrowed type. Variations with no `displayOptions` emit
|
||||
* once at the file-level node header.
|
||||
*/
|
||||
extraTypeDefContent?: BuilderHintVariation[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,23 +10,3 @@ Structured Output Parser: Prefer this over manually extracting/parsing AI output
|
||||
|
||||
Multi-agent systems:
|
||||
AI Agent Tool (@n8n/n8n-nodes-langchain.agentTool) contains an embedded AI Agent — it's a complete sub-agent that the main agent can call through tool(). Each AgentTool needs its own Chat Model. Node selection: 1 AI Agent + N AgentTools + (N+1) Chat Models.`;
|
||||
|
||||
export const AI_TOOL_PATTERNS = `AI Agent tool connection patterns:
|
||||
|
||||
When AI Agent needs external capabilities, use TOOL nodes (not regular nodes):
|
||||
- Research: SerpAPI Tool, Perplexity Tool -> AI Agent [tool()]
|
||||
- Calendar: Google Calendar Tool -> AI Agent [tool()]
|
||||
- Messaging: Slack Tool, Gmail Tool -> AI Agent [tool()]
|
||||
- HTTP calls: HTTP Request Tool -> AI Agent [tool()]
|
||||
- Calculations: Calculator Tool -> AI Agent [tool()]
|
||||
- Sub-agents: AI Agent Tool -> AI Agent [tool()] (for multi-agent systems)
|
||||
|
||||
Tool nodes: AI Agent decides when/if to use them based on reasoning.
|
||||
Regular nodes: Execute at that workflow step regardless of context.
|
||||
|
||||
Vector Store patterns:
|
||||
- Insert documents: Document Loader -> Vector Store (mode='insert') [documentLoader()]
|
||||
- RAG with AI Agent: Vector Store (mode='retrieve-as-tool') -> AI Agent [tool()]
|
||||
The retrieve-as-tool mode makes the Vector Store act as a tool the Agent can call.
|
||||
|
||||
Structured Output Parser: Connect to AI Agent when structured JSON output is required.`;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { AI_NODE_SELECTION, AI_TOOL_PATTERNS } from './ai-nodes';
|
||||
export { NODE_SELECTION_PATTERNS, BASELINE_FLOW_CONTROL } from './use-case-patterns';
|
||||
export { AI_NODE_SELECTION } from './ai-nodes';
|
||||
export { NODE_SELECTION_PATTERNS } from './use-case-patterns';
|
||||
export { TRIGGER_SELECTION } from './trigger-selection';
|
||||
export { NATIVE_NODE_PREFERENCE } from './native-preference';
|
||||
export { CONNECTION_CHANGING_PARAMETERS } from './connection-parameters';
|
||||
|
||||
@@ -44,12 +44,3 @@ CHATBOTS:
|
||||
MEDIA:
|
||||
- OpenAI: DALL-E image generation, Sora video, Whisper transcription
|
||||
- Google Gemini: Imagen image generation`;
|
||||
|
||||
export const BASELINE_FLOW_CONTROL = `Baseline flow control nodes (used in most workflows):
|
||||
|
||||
- n8n-nodes-base.aggregate: Combines multiple items into one item
|
||||
- n8n-nodes-base.if: Routes items based on true/false condition
|
||||
- n8n-nodes-base.switch: Routes items to different paths based on rules or expressions
|
||||
- n8n-nodes-base.splitOut: Expands a single item containing an array into multiple individual items
|
||||
- n8n-nodes-base.merge: Combines data from multiple parallel branches (for 3+ inputs: mode="append" + numberInputs)
|
||||
- n8n-nodes-base.set: Transforms and restructures data fields`;
|
||||
|
||||
@@ -97,6 +97,30 @@ export class Code implements INodeType {
|
||||
relationHint: 'Use this instead for creating html pages',
|
||||
},
|
||||
],
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
content: `<patterns>
|
||||
<pattern title="runOnceForAllItems with $input.all()">
|
||||
const codeNode = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Process Data',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: \`
|
||||
const items = $input.all();
|
||||
return items.map(item => ({
|
||||
json: { ...item.json, processed: true }
|
||||
}));
|
||||
\`.trim()
|
||||
}
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameterPane: 'wide',
|
||||
properties: [
|
||||
|
||||
@@ -27,6 +27,40 @@ export class DataTable implements INodeType {
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
builderHint: {
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
displayOptions: { show: { resource: ['row'], operation: ['insert'] } },
|
||||
content: `<patterns>
|
||||
<pattern title="Insert with explicit schema">
|
||||
const storeData = node({
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
version: 1.1,
|
||||
config: {
|
||||
name: 'Store Data',
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'insert',
|
||||
dataTableId: { __rl: true, mode: 'name', value: 'my-table' },
|
||||
columns: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
name: expr('{{ $json.name }}'),
|
||||
email: expr('{{ $json.email }}')
|
||||
},
|
||||
schema: [
|
||||
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
|
||||
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
hints: [
|
||||
{
|
||||
message: 'The selected data table has no columns.',
|
||||
|
||||
@@ -34,6 +34,10 @@ export const description: INodeProperties[] = [
|
||||
value: get.FIELD,
|
||||
description: 'Get row(s)',
|
||||
action: 'Get row(s)',
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
"There is no `getAll` operation. To fetch many rows, use `operation: 'get'` with `returnAll: true`.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'If Row Exists',
|
||||
@@ -52,6 +56,10 @@ export const description: INodeProperties[] = [
|
||||
value: insert.FIELD,
|
||||
description: 'Insert a new row',
|
||||
action: 'Insert row',
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
'Row IDs are auto-generated. Do NOT define a custom `id` column or seed `id` on insert. The built-in row `id` is valid for filtering update/delete but is not part of the user-defined table schema.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
|
||||
@@ -29,6 +29,10 @@ export class FilterV2 implements INodeType {
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
outputNames: ['Kept', 'Discarded'],
|
||||
parameterPane: 'wide',
|
||||
builderHint: {
|
||||
searchHint:
|
||||
'Filter emits 0 items when nothing matches and the chain stops cleanly — no IF gate needed before downstream loops.',
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Conditions',
|
||||
@@ -43,6 +47,13 @@ export class FilterV2 implements INodeType {
|
||||
version: '={{ $nodeVersion >= 2.3 ? 3 : $nodeVersion >= 2.2 ? 2 : 1 }}',
|
||||
},
|
||||
},
|
||||
builderHint: {
|
||||
propertyHint: `Must always contain these three sibling keys:
|
||||
- combinator: 'and' or 'or', default to 'and'
|
||||
- conditions: [ a list of condition objects ]
|
||||
- options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 1 }
|
||||
e.g.: { combinator: 'and', options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 }, conditions: [{ leftValue: expr('{{ $json.field }}'), rightValue: 'value', operator: { type: 'string', operation: 'equals' } }] }`,
|
||||
},
|
||||
},
|
||||
{
|
||||
...looseTypeValidationProperty,
|
||||
|
||||
@@ -23,6 +23,38 @@ export class GoogleSheets extends VersionedNodeType {
|
||||
relationHint: 'Prefer for workflow data storage with upsert',
|
||||
},
|
||||
],
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append', 'appendOrUpdate', 'update'],
|
||||
},
|
||||
},
|
||||
content: `<patterns>
|
||||
<pattern title="autoMapInputData — maps $json fields to sheet columns automatically">
|
||||
columns: {
|
||||
mappingMode: 'autoMapInputData',
|
||||
value: {},
|
||||
schema: [
|
||||
{ id: 'Name', displayName: 'Name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
|
||||
{ id: 'Email', displayName: 'Email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: false }
|
||||
]
|
||||
}
|
||||
</pattern>
|
||||
<pattern title="defineBelow — explicit expression mapping">
|
||||
columns: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: { name: expr('{{ $json.name }}'), email: expr('{{ $json.email }}') },
|
||||
schema: [
|
||||
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
|
||||
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
|
||||
]
|
||||
}
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -81,7 +81,10 @@ export const descriptions: INodeProperties[] = [
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
builderHint: { propertyHint: "Default to mode: 'list' which is easier for users to set up" },
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
"Default to mode: 'list' which is easier for users to set up. Resource locator value must be `{ __rl: true, mode, value }` — never a plain string or `expr()` wrapper.",
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
@@ -139,7 +142,10 @@ export const descriptions: INodeProperties[] = [
|
||||
default: { mode: 'list', value: '' },
|
||||
// default: '', //empty string set to progresivly reveal fields
|
||||
required: true,
|
||||
builderHint: { propertyHint: "Default to mode: 'list' which is easier for users to set up" },
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
"Default to mode: 'list' which is easier for users to set up. Resource locator value must be `{ __rl: true, mode, value }` — never a plain string or `expr()` wrapper.",
|
||||
},
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['documentId.value'],
|
||||
},
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
type ResourceMapperField,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { cellFormat, handlingExtraData, useAppendOption } from './commonDescription';
|
||||
import {
|
||||
cellFormat,
|
||||
columnsResourceMapperBuilderHint,
|
||||
handlingExtraData,
|
||||
useAppendOption,
|
||||
} from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties, ValueInputOption } from '../../helpers/GoogleSheets.types';
|
||||
import {
|
||||
@@ -127,6 +132,7 @@ export const description: SheetProperties = [
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
builderHint: columnsResourceMapperBuilderHint,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
cellFormat,
|
||||
columnsResourceMapperBuilderHint,
|
||||
handlingExtraData,
|
||||
locationDefine,
|
||||
useAppendOption,
|
||||
@@ -171,6 +172,7 @@ export const description: SheetProperties = [
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
builderHint: columnsResourceMapperBuilderHint,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
@@ -206,6 +208,7 @@ export const description: SheetProperties = [
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
builderHint: columnsResourceMapperBuilderHint,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import type { INodeProperties, IParameterBuilderHint } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Builder hint shared by every Google Sheets `columns` resourceMapper parameter
|
||||
* (append, appendOrUpdate, update). The full resourceMapper object shape is
|
||||
* non-obvious, and a bare string like `'autoMapInputData'` silently fails
|
||||
* validation. The matching `<patterns>` example lives on the node-level
|
||||
* `builderHint.extraTypeDefContent` in `Google/Sheet/GoogleSheets.node.ts`,
|
||||
* gated by `displayOptions: { show: { resource: ['sheet'], operation: [...] } }`.
|
||||
*/
|
||||
export const columnsResourceMapperBuilderHint: IParameterBuilderHint = {
|
||||
propertyHint:
|
||||
"Pass the full resourceMapper object: { mappingMode, value, schema }. A bare string like 'autoMapInputData' fails validation.",
|
||||
};
|
||||
|
||||
export const dataLocationOnSheet: INodeProperties = {
|
||||
displayName: 'Data Location on Sheet',
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError, UserError } from 'n8n-workflow';
|
||||
|
||||
import { cellFormat, handlingExtraData, locationDefine } from './commonDescription';
|
||||
import {
|
||||
cellFormat,
|
||||
columnsResourceMapperBuilderHint,
|
||||
handlingExtraData,
|
||||
locationDefine,
|
||||
} from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import {
|
||||
ROW_NUMBER,
|
||||
@@ -157,6 +162,7 @@ export const description: SheetProperties = [
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
builderHint: columnsResourceMapperBuilderHint,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
@@ -192,6 +198,7 @@ export const description: SheetProperties = [
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
builderHint: columnsResourceMapperBuilderHint,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
|
||||
@@ -29,6 +29,10 @@ export class IfV2 implements INodeType {
|
||||
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
||||
outputNames: ['true', 'false'],
|
||||
parameterPane: 'wide',
|
||||
builderHint: {
|
||||
searchHint:
|
||||
'After configuring, confirm the workflow wires both `.onTrue()` and `.onFalse()` (or only the relevant one) to the correct downstream node — IF has two named outputs and silently drops items routed to an unwired branch.',
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Conditions',
|
||||
|
||||
@@ -13,6 +13,93 @@ export const versionDescription: INodeTypeDescription = {
|
||||
defaults: {
|
||||
name: 'Merge',
|
||||
},
|
||||
builderHint: {
|
||||
searchHint:
|
||||
'Mode selection is the single most consequential decision on this node — the wrong mode silently drops or duplicates items rather than erroring. Pick by data shape: `append` to concatenate items from parallel branches; `combineByPosition` only when both branches emit the same number of items in the same order; `combineByFields` to join by a matching key (default; usually correct for "merge by ID"); `combineBySql` for >2 inputs or aggregation; `chooseBranch` to discard all but one input. Read each mode\'s @builderHint before picking.',
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
content: `<patterns>
|
||||
<pattern title="Combine two branches by matching key (combineByFields, default)">
|
||||
const usersApi = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.2,
|
||||
config: { name: 'Fetch Users', parameters: { url: 'https://api.example.com/users' } }
|
||||
});
|
||||
|
||||
const ordersApi = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.2,
|
||||
config: { name: 'Fetch Orders', parameters: { url: 'https://api.example.com/orders' } }
|
||||
});
|
||||
|
||||
const mergeNode = merge({
|
||||
type: 'n8n-nodes-base.merge',
|
||||
version: 3.2,
|
||||
config: {
|
||||
name: 'Merge by ID',
|
||||
parameters: {
|
||||
mode: 'combine',
|
||||
combineBy: 'combineByFields',
|
||||
fieldsToMatchString: 'id',
|
||||
joinMode: 'keepMatches',
|
||||
outputDataFrom: 'both'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wire each upstream branch to a specific merge input slot.
|
||||
usersApi.to(mergeNode.input(0));
|
||||
ordersApi.to(mergeNode.input(1));
|
||||
</pattern>
|
||||
|
||||
<pattern title="Append items from parallel branches (append)">
|
||||
const branchA = node({
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: { name: 'Branch A', parameters: {} }
|
||||
});
|
||||
|
||||
const branchB = node({
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: { name: 'Branch B', parameters: {} }
|
||||
});
|
||||
|
||||
const mergeNode = merge({
|
||||
type: 'n8n-nodes-base.merge',
|
||||
version: 3.2,
|
||||
config: {
|
||||
name: 'Append',
|
||||
parameters: { mode: 'append', numberInputs: 2 }
|
||||
}
|
||||
});
|
||||
|
||||
branchA.to(mergeNode.input(0));
|
||||
branchB.to(mergeNode.input(1));
|
||||
</pattern>
|
||||
|
||||
<pattern title="Three or more inputs with SQL (combineBySql)">
|
||||
const mergeNode = merge({
|
||||
type: 'n8n-nodes-base.merge',
|
||||
version: 3.2,
|
||||
config: {
|
||||
name: 'SQL Merge',
|
||||
parameters: {
|
||||
mode: 'combineBySql',
|
||||
numberInputs: 3,
|
||||
query: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.id = input2.userId LEFT JOIN input3 ON input1.id = input3.userId'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
input1Node.to(mergeNode.input(0));
|
||||
input2Node.to(mergeNode.input(1));
|
||||
input3Node.to(mergeNode.input(2));
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
inputs: `={{(${configuredInputs})($parameter)}}`,
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
// If mode is chooseBranch data from both branches is required
|
||||
|
||||
@@ -23,6 +23,30 @@ export class SplitInBatchesV3 implements INodeType {
|
||||
|
||||
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
||||
outputNames: ['done', 'loop'],
|
||||
builderHint: {
|
||||
searchHint:
|
||||
"Loop pattern: connect splitInBatches → per-item work → back to splitInBatches via `nextBatch(splitInBatches)`. The `done` output fires automatically after all items are processed. Already no-ops on empty input — do NOT add an IF gate before it to check 'has items?'.",
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
content: `<patterns>
|
||||
<pattern title="Per-item loop using splitInBatches with nextBatch">
|
||||
const sibNode = splitInBatches({
|
||||
version: 3,
|
||||
config: { name: 'Batch Process', parameters: { batchSize: 1 } }
|
||||
});
|
||||
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(fetchRecords)
|
||||
.to(sibNode
|
||||
.onDone(finalizeResults)
|
||||
.onEachBatch(processRecord.to(nextBatch(sibNode)))
|
||||
);
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName:
|
||||
|
||||
@@ -59,6 +59,52 @@ export class SwitchV3 implements INodeType {
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: `={{(${configuredOutputs})($parameter)}}`,
|
||||
builderHint: {
|
||||
extraTypeDefContent: [
|
||||
{
|
||||
displayOptions: { show: { mode: ['rules'] } },
|
||||
content: `<patterns>
|
||||
<pattern title="Switch with two cases plus a default branch">
|
||||
const routeByPriority = switchCase({
|
||||
version: 3.2,
|
||||
config: {
|
||||
name: 'Route by Priority',
|
||||
parameters: {
|
||||
rules: {
|
||||
values: [
|
||||
{
|
||||
outputKey: 'urgent',
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'urgent' }],
|
||||
combinator: 'and'
|
||||
}
|
||||
},
|
||||
{
|
||||
outputKey: 'normal',
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'normal' }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(routeByPriority
|
||||
.onCase('urgent', processUrgent.to(notifyTeam))
|
||||
.onCase('normal', processNormal)
|
||||
.onDefault(archive));
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Mode',
|
||||
@@ -128,6 +174,10 @@ export class SwitchV3 implements INodeType {
|
||||
name: 'rules',
|
||||
placeholder: 'Add Routing Rule',
|
||||
type: 'fixedCollection',
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
"Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Each `outputKey` you define must be wired via `.onCase('<outputKey>')` to the intended downstream node — unwired cases silently drop their items.",
|
||||
},
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
sortable: true,
|
||||
|
||||
@@ -1908,6 +1908,16 @@ export interface INodePropertyCollection {
|
||||
builderHint?: IParameterBuilderHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* One variation of `extraTypeDefContent`, optionally gated by `displayOptions`
|
||||
* so per-mode / per-resource / per-operation examples land only in their
|
||||
* corresponding narrowed type without cross-bleed.
|
||||
*/
|
||||
export interface IBuilderHintVariation {
|
||||
content: string;
|
||||
displayOptions?: IDisplayOptions;
|
||||
}
|
||||
|
||||
export interface IParameterBuilderHint {
|
||||
propertyHint: string;
|
||||
placeholderSupported?: boolean;
|
||||
@@ -2555,6 +2565,14 @@ export interface IBuilderHint {
|
||||
searchHint?: string;
|
||||
/** Related nodes that work together with this node */
|
||||
relatedNodes?: IRelatedNode[];
|
||||
/**
|
||||
* Multi-line content (typically code examples wrapped in `<patterns>...</patterns>`)
|
||||
* emitted into the generated workflow-sdk `.d.ts` but NOT surfaced in
|
||||
* `nodes(action="search")` results. Each variation may carry `displayOptions`
|
||||
* so per-mode / per-resource / per-operation examples land only in their
|
||||
* corresponding narrowed type.
|
||||
*/
|
||||
extraTypeDefContent?: IBuilderHintVariation[];
|
||||
}
|
||||
|
||||
export interface INodeTypeDescription extends INodeTypeBaseDescription {
|
||||
|
||||
Reference in New Issue
Block a user