mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 01:45:48 +08:00
feat(core): Guide Instance AI credential selection (#37447)
This commit is contained in:
@@ -135,6 +135,7 @@ of the breaking removal on `3.x`.
|
||||
`master` is dropped as empty.
|
||||
3. Conflicts confined to **mechanical files** — tool-generated content with a deterministic
|
||||
resolution (`pnpm-lock.yaml`, `packages/frontend/editor-ui/data/node-popularity.json`,
|
||||
`packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json`, and
|
||||
`.github/test-metrics/e2e-impact-map.json`) — are **auto-resolved during the replay**,
|
||||
exactly as a human resolver would: the lockfile is regenerated with
|
||||
`pnpm install --lockfile-only` (pnpm merges its own conflict markers), bot-maintained data
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const outputFile = resolve(
|
||||
repositoryRoot,
|
||||
'packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json',
|
||||
);
|
||||
const endpoint = process.env.N8N_CREDENTIAL_SETUPABILITY_ENDPOINT;
|
||||
|
||||
const roundSetupability = (value) => (value === null ? null : Math.round(value * 20) / 20);
|
||||
export const roundPopularity = (value) => (value === null ? null : Math.round(value * 10) / 10);
|
||||
|
||||
async function main() {
|
||||
if (!endpoint) {
|
||||
throw new Error('N8N_CREDENTIAL_SETUPABILITY_ENDPOINT is required.');
|
||||
}
|
||||
|
||||
console.log('Fetching credential setupability data.');
|
||||
const response = await fetch(endpoint, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Credential setupability endpoint returned HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error('Credential setupability endpoint returned no data.');
|
||||
}
|
||||
|
||||
const metrics = data.map(({ id, setupability, popularity }) => ({
|
||||
id,
|
||||
setupability: roundSetupability(setupability),
|
||||
popularity: roundPopularity(popularity),
|
||||
}));
|
||||
|
||||
await writeFile(outputFile, `${JSON.stringify(metrics, null, '\t')}\n`, 'utf8');
|
||||
console.log(`Saved credential setupability data for ${metrics.length} credential types.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { roundPopularity } from './fetch-credential-setupability.mjs';
|
||||
|
||||
describe('roundPopularity', () => {
|
||||
it('preserves a missing value', () => {
|
||||
assert.equal(roundPopularity(null), null);
|
||||
});
|
||||
|
||||
it('rounds a value to one decimal place', () => {
|
||||
assert.equal(roundPopularity(0.26), 0.3);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,7 @@ export const LOCKFILE = 'pnpm-lock.yaml';
|
||||
export const MECHANICAL_PATHS = {
|
||||
[LOCKFILE]: 'pnpm-regen',
|
||||
'packages/frontend/editor-ui/data/node-popularity.json': 'take-master',
|
||||
'packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json': 'take-master',
|
||||
'.github/test-metrics/e2e-impact-map.json': 'take-master',
|
||||
};
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const PRE_HEAD = 'PREHEAD';
|
||||
const MASTER = 'MASTERSHA';
|
||||
const MERGE_TREE = 'MERGETREEOID';
|
||||
const POPULARITY = 'packages/frontend/editor-ui/data/node-popularity.json';
|
||||
const SETUPABILITY = 'packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json';
|
||||
|
||||
const isRebase = (a) => a[0] === 'rebase' && a[1] !== '--abort';
|
||||
const favouringOwnSide = (a) => a[0] === 'rebase' && a.includes('-X') && a.includes('theirs');
|
||||
@@ -138,9 +139,14 @@ test('classifyPaths and blocksLockfileRegen split mechanical from code conflicts
|
||||
const { mechanical, code } = classifyPaths([
|
||||
LOCKFILE,
|
||||
'packages/cli/x.ts',
|
||||
SETUPABILITY,
|
||||
'.github/test-metrics/e2e-impact-map.json',
|
||||
]);
|
||||
assert.deepEqual(mechanical, [
|
||||
LOCKFILE,
|
||||
SETUPABILITY,
|
||||
'.github/test-metrics/e2e-impact-map.json',
|
||||
]);
|
||||
assert.deepEqual(mechanical, [LOCKFILE, '.github/test-metrics/e2e-impact-map.json']);
|
||||
assert.deepEqual(code, ['packages/cli/x.ts']);
|
||||
|
||||
assert.equal(blocksLockfileRegen(['packages/cli/package.json']), true);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
name: 'Util: Update Credential Setupability'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Monday at 00:30 UTC, after the node-popularity refresh starts.
|
||||
- cron: '30 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-credential-setupability
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update-setupability:
|
||||
if: >-
|
||||
github.repository == 'n8n-io/n8n' &&
|
||||
vars.N8N_CREDENTIAL_SETUPABILITY_ENDPOINT != ''
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
pull-request-number: ${{ steps.create-pr.outputs.pull-request-number }}
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
|
||||
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js and dependencies
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: ''
|
||||
|
||||
- name: Fetch credential setupability data
|
||||
run: node .github/scripts/fetch-credential-setupability.mjs
|
||||
env:
|
||||
N8N_CREDENTIAL_SETUPABILITY_ENDPOINT: ${{ vars.N8N_CREDENTIAL_SETUPABILITY_ENDPOINT }}
|
||||
|
||||
- name: Format generated file
|
||||
run: >-
|
||||
pnpm biome format --write
|
||||
packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain -- packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json)" ]; then
|
||||
echo "No changes to credential setupability data"
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Credential setupability data has changed"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.check-changes.outputs.has_changes == 'true'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
add-paths: packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json
|
||||
commit-message: 'chore: Update credential setupability data'
|
||||
labels: 'automation:scheduled-update'
|
||||
title: 'chore: Update credential setupability data'
|
||||
body: |
|
||||
This automated PR updates the credential setupability data used by Instance AI node discovery.
|
||||
|
||||
The data is fetched weekly from the internal telemetry endpoint. It contains rounded credential-type setup completion and popularity scores.
|
||||
|
||||
_Generated by the weekly credential setupability update workflow._
|
||||
branch: update-credential-setupability
|
||||
base: master
|
||||
delete-branch: true
|
||||
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||
|
||||
approve-and-automerge:
|
||||
needs: [update-setupability]
|
||||
if: >-
|
||||
needs.update-setupability.outputs.pull-request-number != '' &&
|
||||
github.repository == 'n8n-io/n8n'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
# The callee's app credentials live on the release environment and cannot
|
||||
# be forwarded as named secrets by the caller.
|
||||
uses: ./.github/workflows/util-approve-and-set-automerge.yml # zizmor: ignore[secrets-inherit]
|
||||
secrets: inherit
|
||||
with:
|
||||
pull-request-number: ${{ needs.update-setupability.outputs.pull-request-number }}
|
||||
@@ -237,6 +237,30 @@ describe('transcriptAsText', () => {
|
||||
expect(text).toContain('prompt: Here is the plan, approve?');
|
||||
expect(text).toContain('user feedback: No — use a Webhook trigger, not a Schedule');
|
||||
});
|
||||
|
||||
it('surfaces ask-user question types for process expectations', () => {
|
||||
const transcript: TranscriptTurn[] = [
|
||||
{
|
||||
steps: [
|
||||
{
|
||||
kind: 'ask-user',
|
||||
questions: [
|
||||
{
|
||||
id: 'service',
|
||||
question: 'Which service?',
|
||||
type: 'single',
|
||||
options: ['RocketChat', 'Zulip'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(transcriptAsText(transcript)).toContain(
|
||||
'Q (single): Which service? [RocketChat / Zulip]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('perTurnToolCallCounts', () => {
|
||||
|
||||
@@ -206,7 +206,9 @@ describe('buildTranscriptFromEvents', () => {
|
||||
});
|
||||
|
||||
describe('ask-user routing', () => {
|
||||
const questions = [{ id: 'q1', question: 'Which channels?' }];
|
||||
const questions = [
|
||||
{ id: 'q1', question: 'Which channel?', type: 'single', options: ['Slack', 'Teams'] },
|
||||
];
|
||||
|
||||
it('renders ask-user from confirmation-request and skips the tool-call twin', () => {
|
||||
const turns = buildTranscriptFromEvents({
|
||||
@@ -222,7 +224,7 @@ describe('buildTranscriptFromEvents', () => {
|
||||
'r1',
|
||||
{
|
||||
kind: 'questions' as const,
|
||||
answers: [{ questionId: 'q1', selectedOptions: ['#general'] }],
|
||||
answers: [{ questionId: 'q1', selectedOptions: ['Teams'] }],
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -231,8 +233,10 @@ describe('buildTranscriptFromEvents', () => {
|
||||
expect(interactions).toHaveLength(1);
|
||||
expect(interactions[0]).toMatchObject({
|
||||
kind: 'ask-user',
|
||||
questions: [{ id: 'q1', question: 'Which channels?' }],
|
||||
answers: [{ questionId: 'q1', selectedOptions: ['#general'] }],
|
||||
questions: [
|
||||
{ id: 'q1', question: 'Which channel?', type: 'single', options: ['Slack', 'Teams'] },
|
||||
],
|
||||
answers: [{ questionId: 'q1', selectedOptions: ['Teams'] }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -196,7 +196,14 @@ describe('transcript rendering', () => {
|
||||
steps: [
|
||||
{
|
||||
kind: 'ask-user',
|
||||
questions: [{ id: 'q1', question: 'Which channel?', options: ['#a', '#b'] }],
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Which channel?',
|
||||
type: 'single',
|
||||
options: ['#a', '#b'],
|
||||
},
|
||||
],
|
||||
answers: [{ questionId: 'q1', selectedOptions: [], skipped: true }],
|
||||
},
|
||||
],
|
||||
@@ -206,6 +213,7 @@ describe('transcript rendering', () => {
|
||||
const html = generateWorkflowReport([result]);
|
||||
expect(html).toContain('👤 (skipped)');
|
||||
expect(html).toContain('ask-user (with answers)');
|
||||
expect(html).toContain('<code>single</code>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -446,10 +446,14 @@ export function extractAskUserQuestions(raw: unknown[]): AskUserQuestion[] {
|
||||
if (!isRecord(item)) continue;
|
||||
const id = typeof item.id === 'string' ? item.id : '';
|
||||
const question = typeof item.question === 'string' ? item.question : '';
|
||||
const type =
|
||||
item.type === 'single' || item.type === 'multi' || item.type === 'text'
|
||||
? item.type
|
||||
: undefined;
|
||||
const options = Array.isArray(item.options)
|
||||
? item.options.filter((o): o is string => typeof o === 'string')
|
||||
: undefined;
|
||||
if (id || question) questions.push({ id, question, options });
|
||||
if (id || question) questions.push({ id, question, type, options });
|
||||
}
|
||||
return questions;
|
||||
}
|
||||
|
||||
@@ -1018,6 +1018,7 @@ function renderInteraction(interaction: ToolInteraction): string | null {
|
||||
}
|
||||
const lines = interaction.questions
|
||||
.map((q) => {
|
||||
const type = q.type ? ` <code>${escapeHtml(q.type)}</code>` : '';
|
||||
const opts =
|
||||
q.options && q.options.length > 0
|
||||
? ` <em>(${q.options.map((o) => escapeHtml(o)).join(' / ')})</em>`
|
||||
@@ -1026,7 +1027,7 @@ function renderInteraction(interaction: ToolInteraction): string | null {
|
||||
const answerHtml = answer
|
||||
? `<div class="transcript-answer">👤 ${escapeHtml(answer)}</div>`
|
||||
: '';
|
||||
return `<li>${escapeHtml(q.question)}${opts}${answerHtml}</li>`;
|
||||
return `<li>${escapeHtml(q.question)}${type}${opts}${answerHtml}</li>`;
|
||||
})
|
||||
.join('');
|
||||
const summary =
|
||||
|
||||
@@ -448,6 +448,7 @@ export interface PlanTask {
|
||||
export interface AskUserQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
type?: 'single' | 'multi' | 'text';
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -235,9 +235,10 @@ function describeInteraction(interaction: ToolInteraction): string | null {
|
||||
}
|
||||
const qs = interaction.questions
|
||||
.map((q) => {
|
||||
const type = q.type ? ` (${q.type})` : '';
|
||||
const opts = q.options && q.options.length > 0 ? ` [${q.options.join(' / ')}]` : '';
|
||||
const answer = answerByQId.get(q.id);
|
||||
return `Q: ${q.question}${opts}${answer ? ` -> A: ${answer}` : ''}`;
|
||||
return `Q${type}: ${q.question}${opts}${answer ? ` -> A: ${answer}` : ''}`;
|
||||
})
|
||||
.join(' | ');
|
||||
return `Asked user: ${qs}`;
|
||||
|
||||
@@ -394,6 +394,41 @@ decision after testing.
|
||||
- Always declare `output` on nodes that use unresolved credentials when mock
|
||||
data is needed for verification.
|
||||
|
||||
## Credential Setup Preference
|
||||
|
||||
Discovery results can include a `setupPreference` array. Each entry has:
|
||||
|
||||
- `type`, the credential type
|
||||
- `setupCompletionPercent`, a percentage from 0 to 100 rounded to the nearest
|
||||
5 percentage points, or `null`
|
||||
- `popularityScore`, a relative adoption score from 0 to 1 rounded to one
|
||||
decimal place, or `null`
|
||||
|
||||
Setup completion measures completion of an Instance AI setup step containing
|
||||
the credential; it is not an activation or validity rate. For either metric,
|
||||
`null` means there was not enough data. Popularity is relative recent adoption,
|
||||
not a percentage. Treat both as coarse signals and ignore small differences.
|
||||
|
||||
When choosing a service:
|
||||
|
||||
1. Honor explicit intent and existing workflow choices.
|
||||
2. Prefer a semantically suitable service with a usable existing credential,
|
||||
then apply the existing Gateway credits rules.
|
||||
3. Compare setup preference only among the remaining semantically
|
||||
interchangeable candidates. Before deciding, inspect discovery results for
|
||||
every candidate the user named.
|
||||
|
||||
- When setup completion and popularity clearly support one candidate, choose it
|
||||
and continue without asking.
|
||||
- When the signals are close or conflict and the user has not delegated the
|
||||
choice, ask exactly one `single` question. If skipped, choose a sensible default.
|
||||
- When the user explicitly asks you to choose, make a sensible choice and
|
||||
continue without asking.
|
||||
|
||||
Use judgment instead of calculating a combined score or applying a fixed
|
||||
threshold. Never let this metadata override stronger semantic relevance or use
|
||||
it to choose between authentication methods for the same service.
|
||||
|
||||
## Gateway credits Preference
|
||||
|
||||
"Gateway credits" is the user-facing name of n8n's managed credential
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import {
|
||||
NodeSearchEngine,
|
||||
suggestedNodesData,
|
||||
type SearchableNodeType,
|
||||
} from '@n8n/ai-utilities/node-catalog';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
import { addSetupPreference } from '../nodes/setup-preference';
|
||||
import { createNodesTool } from '../nodes.tool';
|
||||
|
||||
function createMockContext(overrides: Partial<InstanceAiContext> = {}): InstanceAiContext {
|
||||
@@ -181,6 +187,10 @@ describe('nodes tool', () => {
|
||||
];
|
||||
const context = createMockContext();
|
||||
(context.nodeService.listSearchable as Mock).mockResolvedValue(searchableNodes);
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
properties: [{ type: 'credentialsSelect' }],
|
||||
credentials: [{ name: 'gmailOAuth2' }],
|
||||
});
|
||||
|
||||
const tool = createNodesTool(context, 'full');
|
||||
const first = await executeTool(
|
||||
@@ -203,6 +213,8 @@ describe('nodes tool', () => {
|
||||
totalResults: 1,
|
||||
results: [expect.objectContaining({ name: 'n8n-nodes-base.httpRequest' })],
|
||||
});
|
||||
expect(context.nodeService.getDescription).toHaveBeenCalledTimes(2);
|
||||
expect(first).not.toHaveProperty('results.0.setupPreference');
|
||||
});
|
||||
|
||||
it('should search nodes by connection type and enrich results with discriminators', async () => {
|
||||
@@ -355,6 +367,105 @@ describe('nodes tool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup preference', () => {
|
||||
it('should expose credential preferences without changing existing data or order', async () => {
|
||||
const expectedTelegram = addSetupPreference({}, ['telegramApi']).setupPreference;
|
||||
const expectedGmail = addSetupPreference({}, ['gmailOAuth2', 'googleApi']).setupPreference;
|
||||
const credentialsByNode = new Map([
|
||||
['n8n-nodes-base.gmail', ['gmailOAuth2', 'googleApi']],
|
||||
['n8n-nodes-base.httpRequest', ['gmailOAuth2']],
|
||||
['n8n-nodes-base.telegram', ['telegramApi']],
|
||||
]);
|
||||
const searchableNodes = [
|
||||
{
|
||||
name: 'n8n-nodes-base.telegram',
|
||||
displayName: 'Telegram',
|
||||
description: 'Send a notification message',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
version: 1,
|
||||
aiGateway: { supported: true, minVersion: 1 },
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.unknownSetupability',
|
||||
displayName: 'Unknown Setupability',
|
||||
description: 'Send a notification message',
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
version: 1,
|
||||
},
|
||||
] satisfies SearchableNodeType[];
|
||||
const expectedSearchResults = new NodeSearchEngine(searchableNodes).searchByName(
|
||||
'message',
|
||||
5,
|
||||
);
|
||||
const context = createMockContext();
|
||||
(context.nodeService.listSearchable as Mock).mockResolvedValue(searchableNodes);
|
||||
(context.nodeService.getDescription as Mock).mockImplementation((nodeType: string) => ({
|
||||
properties:
|
||||
nodeType === 'n8n-nodes-base.httpRequest' ? [{ type: 'credentialsSelect' }] : [],
|
||||
credentials: (credentialsByNode.get(nodeType) ?? []).map((name) => ({ name })),
|
||||
}));
|
||||
context.nodeService.listDiscriminators = vi.fn().mockResolvedValue({
|
||||
resource: ['message'],
|
||||
});
|
||||
|
||||
const tool = createNodesTool(context, 'full');
|
||||
const searchResult = await executeTool<{
|
||||
results: Array<{
|
||||
name: string;
|
||||
score: number;
|
||||
note?: string;
|
||||
aiGateway?: unknown;
|
||||
discriminators?: unknown;
|
||||
setupPreference?: unknown;
|
||||
}>;
|
||||
}>(tool, { action: 'search', query: 'message', limit: 5 } as never, {} as never);
|
||||
const suggestedResult = await executeTool<{
|
||||
results: Array<{
|
||||
suggestedNodes: Array<{
|
||||
name: string;
|
||||
note?: string;
|
||||
setupPreference?: unknown;
|
||||
}>;
|
||||
}>;
|
||||
}>(tool, { action: 'suggested', categories: ['notification'] } as never, {} as never);
|
||||
|
||||
expect(searchResult.results.map(({ name, score }) => ({ name, score }))).toEqual(
|
||||
expectedSearchResults.map(({ name, score }) => ({ name, score })),
|
||||
);
|
||||
const searchTelegram = searchResult.results.find(
|
||||
(node) => node.name === 'n8n-nodes-base.telegram',
|
||||
);
|
||||
const searchUnknown = searchResult.results.find(
|
||||
(node) => node.name === 'n8n-nodes-base.unknownSetupability',
|
||||
);
|
||||
const notificationNodes = suggestedResult.results[0]?.suggestedNodes;
|
||||
const suggestedTelegram = notificationNodes?.find(
|
||||
(node) => node.name === 'n8n-nodes-base.telegram',
|
||||
);
|
||||
|
||||
expect(searchTelegram).toMatchObject({
|
||||
aiGateway: { supported: true, minVersion: 1 },
|
||||
discriminators: { resource: ['message'] },
|
||||
setupPreference: expectedTelegram,
|
||||
});
|
||||
expect(suggestedTelegram?.setupPreference).toEqual(searchTelegram?.setupPreference);
|
||||
expect(searchUnknown).not.toHaveProperty('setupPreference');
|
||||
expect(notificationNodes?.map(({ name }) => name)).toEqual(
|
||||
suggestedNodesData.notification.nodes.map(({ name }) => name),
|
||||
);
|
||||
expect(notificationNodes?.find((node) => node.name === 'n8n-nodes-base.gmail')).toEqual({
|
||||
name: 'n8n-nodes-base.gmail',
|
||||
note: "Default to this because it's easy for users to setup",
|
||||
setupPreference: expectedGmail,
|
||||
});
|
||||
expect(
|
||||
notificationNodes?.find((node) => node.name === 'n8n-nodes-base.httpRequest'),
|
||||
).not.toHaveProperty('setupPreference');
|
||||
});
|
||||
});
|
||||
|
||||
describe('explore-resources action', () => {
|
||||
it('should return error when exploreResources is not available', async () => {
|
||||
const context = createMockContext();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NodeSearchEngine,
|
||||
categoryList,
|
||||
suggestedNodesData,
|
||||
type CategorySuggestedNode,
|
||||
type SearchableNodeType,
|
||||
} from '@n8n/ai-utilities/node-catalog';
|
||||
import { z } from 'zod';
|
||||
@@ -14,6 +15,7 @@ import { z } from 'zod';
|
||||
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
|
||||
import type { InstanceAiContext } from '../types';
|
||||
import { pickPreferredChatModelNode } from './nodes/preferred-chat-model';
|
||||
import { addSetupPreference, type NodeWithSetupPreference } from './nodes/setup-preference';
|
||||
import { buildCredentialMap } from './workflows/resolve-credentials';
|
||||
|
||||
// ── Action schemas ──────────────────────────────────────────────────────────
|
||||
@@ -145,6 +147,21 @@ interface SearchEngineCache {
|
||||
engine?: NodeSearchEngine;
|
||||
}
|
||||
|
||||
async function enrichWithSetupPreference<T extends { name: string }>(
|
||||
context: InstanceAiContext,
|
||||
node: T,
|
||||
version?: number,
|
||||
): Promise<NodeWithSetupPreference<T>> {
|
||||
try {
|
||||
const description = await context.nodeService.getDescription(node.name, version);
|
||||
if (description.properties.some(({ type }) => type === 'credentialsSelect')) return node;
|
||||
|
||||
return addSetupPreference(node, description.credentials?.map(({ name }) => name) ?? []);
|
||||
} catch {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleList(
|
||||
@@ -181,13 +198,15 @@ async function handleSearch(
|
||||
return { results: [], totalResults: 0 };
|
||||
}
|
||||
|
||||
// Enrich results with discriminator info (resources/operations) when available
|
||||
// Enrich results with discriminator and credential setup metadata when available.
|
||||
const enriched = await Promise.all(
|
||||
results.map(async (r) => {
|
||||
if (!context.nodeService.listDiscriminators) return r;
|
||||
const disc = await context.nodeService.listDiscriminators(r.name);
|
||||
if (!disc) return r;
|
||||
return { ...r, discriminators: disc };
|
||||
const [node, discriminators] = await Promise.all([
|
||||
enrichWithSetupPreference(context, r, r.version),
|
||||
context.nodeService.listDiscriminators?.(r.name) ?? Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return discriminators ? { ...node, discriminators } : node;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -323,24 +342,29 @@ async function handleTypeDefinition(
|
||||
return await resolveNodeTypeDefinitions(context, parsed.data.nodeTypes);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async function handleSuggested(input: Extract<FullInput, { action: 'suggested' }>) {
|
||||
async function handleSuggested(
|
||||
context: InstanceAiContext,
|
||||
input: Extract<FullInput, { action: 'suggested' }>,
|
||||
) {
|
||||
const results: Array<{
|
||||
category: string;
|
||||
description: string;
|
||||
patternHint: string;
|
||||
suggestedNodes: Array<{ name: string; note?: string }>;
|
||||
suggestedNodes: Array<NodeWithSetupPreference<CategorySuggestedNode>>;
|
||||
}> = [];
|
||||
const unknownCategories: string[] = [];
|
||||
|
||||
for (const cat of input.categories) {
|
||||
const data = suggestedNodesData[cat];
|
||||
if (data) {
|
||||
const suggestedNodes = await Promise.all(
|
||||
data.nodes.map(async (node) => await enrichWithSetupPreference(context, node)),
|
||||
);
|
||||
results.push({
|
||||
category: cat,
|
||||
description: data.description,
|
||||
patternHint: data.patternHint,
|
||||
suggestedNodes: data.nodes,
|
||||
suggestedNodes,
|
||||
});
|
||||
} else {
|
||||
unknownCategories.push(cat);
|
||||
@@ -447,7 +471,7 @@ export function createNodesTool(
|
||||
case 'type-definition':
|
||||
return await handleTypeDefinition(context, input);
|
||||
case 'suggested':
|
||||
return await handleSuggested(input);
|
||||
return await handleSuggested(context, input);
|
||||
case 'explore-resources':
|
||||
return await handleExploreResources(context, input);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { addSetupPreference } from '../setup-preference';
|
||||
|
||||
vi.mock('../credential-setupability.json', () => ({
|
||||
default: [
|
||||
{ id: 'knownCredential', setupability: 0.42, popularity: 0.26 },
|
||||
{ id: 'unknownCredential', setupability: null, popularity: null },
|
||||
],
|
||||
}));
|
||||
|
||||
describe('credential setup preference with missing data', () => {
|
||||
it('preserves missing popularity and rounds known popularity', () => {
|
||||
expect(addSetupPreference({}, ['knownCredential', 'unknownCredential'])).toEqual({
|
||||
setupPreference: [
|
||||
{
|
||||
type: 'knownCredential',
|
||||
setupCompletionPercent: 42,
|
||||
popularityScore: 0.3,
|
||||
},
|
||||
{
|
||||
type: 'unknownCredential',
|
||||
setupCompletionPercent: null,
|
||||
popularityScore: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import credentialSetupability from '../credential-setupability.json';
|
||||
import { addSetupPreference } from '../setup-preference';
|
||||
|
||||
function expectedPreference(metric: (typeof credentialSetupability)[number]) {
|
||||
return {
|
||||
type: metric.id,
|
||||
setupCompletionPercent:
|
||||
metric.setupability === null ? null : Math.round(metric.setupability * 100),
|
||||
popularityScore: metric.popularity === null ? null : Math.round(metric.popularity * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
describe('credential setupability data', () => {
|
||||
it('should contain canonical credential metrics', () => {
|
||||
expect(credentialSetupability.length).toBeGreaterThan(0);
|
||||
expect(new Set(credentialSetupability.map(({ id }) => id)).size).toBe(
|
||||
credentialSetupability.length,
|
||||
);
|
||||
|
||||
for (const metric of credentialSetupability) {
|
||||
expect(Object.keys(metric).sort()).toEqual(['id', 'popularity', 'setupability']);
|
||||
expect(metric.id.trim()).toBe(metric.id);
|
||||
expect(metric.id.length).toBeGreaterThan(0);
|
||||
if (metric.setupability !== null) {
|
||||
expect(metric.setupability).toBeGreaterThanOrEqual(0);
|
||||
expect(metric.setupability).toBeLessThanOrEqual(1);
|
||||
expect(metric.setupability * 20).toBeCloseTo(Math.round(metric.setupability * 20));
|
||||
}
|
||||
if (metric.popularity !== null) {
|
||||
expect(metric.popularity).toBeGreaterThanOrEqual(0);
|
||||
expect(metric.popularity).toBeLessThanOrEqual(1);
|
||||
expect(metric.popularity * 10).toBeCloseTo(Math.round(metric.popularity * 10));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should attach metrics only for credentials supported by the node', () => {
|
||||
const knownMetric = credentialSetupability.find(({ setupability }) => setupability !== null);
|
||||
const unknownMetric = credentialSetupability.find(({ setupability }) => setupability === null);
|
||||
if (!knownMetric || !unknownMetric) throw new Error('Expected known and unknown setupability');
|
||||
const credentialTypes = [knownMetric.id, unknownMetric.id, knownMetric.id, 'missing'];
|
||||
const node = { name: 'n8n-nodes-base.gmail' };
|
||||
|
||||
expect(addSetupPreference(node, credentialTypes)).toEqual({
|
||||
...node,
|
||||
setupPreference: [expectedPreference(knownMetric), expectedPreference(unknownMetric)].sort(
|
||||
(left, right) => left.type.localeCompare(right.type),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave nodes without matching credential metrics unchanged', () => {
|
||||
const node = { name: 'n8n-nodes-base.unknownSetupability' };
|
||||
|
||||
expect(addSetupPreference(node, ['unknownCredential'])).toBe(node);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
import credentialSetupability from './credential-setupability.json';
|
||||
|
||||
export interface SetupPreference {
|
||||
type: string;
|
||||
setupCompletionPercent: number | null;
|
||||
popularityScore: number | null;
|
||||
}
|
||||
|
||||
export type NodeWithSetupPreference<T extends object> = T & {
|
||||
setupPreference?: SetupPreference[];
|
||||
};
|
||||
|
||||
const setupPreferences = new Map<string, SetupPreference>();
|
||||
|
||||
for (const { id, setupability, popularity } of credentialSetupability) {
|
||||
setupPreferences.set(id, {
|
||||
type: id,
|
||||
setupCompletionPercent: setupability === null ? null : Math.round(setupability * 100),
|
||||
popularityScore: popularity === null ? null : Math.round(popularity * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
export function addSetupPreference<T extends object>(
|
||||
node: T,
|
||||
credentialTypes: readonly string[],
|
||||
): NodeWithSetupPreference<T> {
|
||||
const credentials = [...new Set(credentialTypes)]
|
||||
.map((credentialType) => setupPreferences.get(credentialType))
|
||||
.filter((preference): preference is SetupPreference => preference !== undefined)
|
||||
.sort((left, right) => left.type.localeCompare(right.type));
|
||||
|
||||
return credentials.length > 0 ? { ...node, setupPreference: credentials } : node;
|
||||
}
|
||||
@@ -5,6 +5,6 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.json"],
|
||||
"exclude": ["src/**/__tests__/**", "src/**/test-utils/**", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user