From be678fcb69640721ea8551fe3938d294745aa7d7 Mon Sep 17 00:00:00 2001 From: Predrag Ristic Date: Fri, 4 Sep 2026 13:04:42 +0000 Subject: [PATCH] feat(core): Guide Instance AI credential selection (#37447) --- .github/DEVELOPING_V3.md | 1 + .../scripts/fetch-credential-setupability.mjs | 47 + .../fetch-credential-setupability.test.mjs | 14 + .github/scripts/sync-master-to-3x.mjs | 1 + .github/scripts/sync-master-to-3x.test.mjs | 8 +- .../util-update-credential-setupability.yml | 102 + .../__tests__/conversation-text.test.ts | 24 + .../__tests__/transcript-from-events.test.ts | 12 +- .../__tests__/workflow-report.test.ts | 10 +- .../outcome/transcript-from-events.ts | 6 +- .../evaluations/report/workflow-report.ts | 3 +- .../@n8n/instance-ai/evaluations/types.ts | 1 + .../evaluations/utils/conversation-text.ts | 3 +- .../skills/workflow-builder/SKILL.md | 35 + .../src/tools/__tests__/nodes.tool.test.ts | 111 + .../@n8n/instance-ai/src/tools/nodes.tool.ts | 44 +- .../__tests__/setup-preference-null.test.ts | 27 + .../nodes/__tests__/setup-preference.test.ts | 57 + .../tools/nodes/credential-setupability.json | 1792 +++++++++++++++++ .../src/tools/nodes/setup-preference.ts | 33 + packages/@n8n/instance-ai/tsconfig.build.json | 2 +- 21 files changed, 2313 insertions(+), 20 deletions(-) create mode 100644 .github/scripts/fetch-credential-setupability.mjs create mode 100644 .github/scripts/fetch-credential-setupability.test.mjs create mode 100644 .github/workflows/util-update-credential-setupability.yml create mode 100644 packages/@n8n/instance-ai/src/tools/nodes/__tests__/setup-preference-null.test.ts create mode 100644 packages/@n8n/instance-ai/src/tools/nodes/__tests__/setup-preference.test.ts create mode 100644 packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json create mode 100644 packages/@n8n/instance-ai/src/tools/nodes/setup-preference.ts diff --git a/.github/DEVELOPING_V3.md b/.github/DEVELOPING_V3.md index 09f57dd588f..399cd87d1da 100644 --- a/.github/DEVELOPING_V3.md +++ b/.github/DEVELOPING_V3.md @@ -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 diff --git a/.github/scripts/fetch-credential-setupability.mjs b/.github/scripts/fetch-credential-setupability.mjs new file mode 100644 index 00000000000..25e09839eef --- /dev/null +++ b/.github/scripts/fetch-credential-setupability.mjs @@ -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; + }); +} diff --git a/.github/scripts/fetch-credential-setupability.test.mjs b/.github/scripts/fetch-credential-setupability.test.mjs new file mode 100644 index 00000000000..6b1065d76d5 --- /dev/null +++ b/.github/scripts/fetch-credential-setupability.test.mjs @@ -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); + }); +}); diff --git a/.github/scripts/sync-master-to-3x.mjs b/.github/scripts/sync-master-to-3x.mjs index 8418133b757..3d6013a5992 100644 --- a/.github/scripts/sync-master-to-3x.mjs +++ b/.github/scripts/sync-master-to-3x.mjs @@ -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', }; diff --git a/.github/scripts/sync-master-to-3x.test.mjs b/.github/scripts/sync-master-to-3x.test.mjs index 0a807f0e0c7..cf41390943a 100644 --- a/.github/scripts/sync-master-to-3x.test.mjs +++ b/.github/scripts/sync-master-to-3x.test.mjs @@ -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); diff --git a/.github/workflows/util-update-credential-setupability.yml b/.github/workflows/util-update-credential-setupability.yml new file mode 100644 index 00000000000..bc7adb5813a --- /dev/null +++ b/.github/workflows/util-update-credential-setupability.yml @@ -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 }} diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/conversation-text.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/conversation-text.test.ts index 5c27f73e578..4c716321025 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/conversation-text.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/conversation-text.test.ts @@ -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', () => { diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/transcript-from-events.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/transcript-from-events.test.ts index a8f295a3da6..468ec16af9d 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/transcript-from-events.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/transcript-from-events.test.ts @@ -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'] }], }); }); diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/workflow-report.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/workflow-report.test.ts index 92dd066f079..2364a58506f 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/workflow-report.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/workflow-report.test.ts @@ -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('single'); }); }); diff --git a/packages/@n8n/instance-ai/evaluations/outcome/transcript-from-events.ts b/packages/@n8n/instance-ai/evaluations/outcome/transcript-from-events.ts index 21bebda2cef..b7972de4ef2 100644 --- a/packages/@n8n/instance-ai/evaluations/outcome/transcript-from-events.ts +++ b/packages/@n8n/instance-ai/evaluations/outcome/transcript-from-events.ts @@ -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; } diff --git a/packages/@n8n/instance-ai/evaluations/report/workflow-report.ts b/packages/@n8n/instance-ai/evaluations/report/workflow-report.ts index 7465c0fa1ab..f91382cbe16 100644 --- a/packages/@n8n/instance-ai/evaluations/report/workflow-report.ts +++ b/packages/@n8n/instance-ai/evaluations/report/workflow-report.ts @@ -1018,6 +1018,7 @@ function renderInteraction(interaction: ToolInteraction): string | null { } const lines = interaction.questions .map((q) => { + const type = q.type ? ` ${escapeHtml(q.type)}` : ''; const opts = q.options && q.options.length > 0 ? ` (${q.options.map((o) => escapeHtml(o)).join(' / ')})` @@ -1026,7 +1027,7 @@ function renderInteraction(interaction: ToolInteraction): string | null { const answerHtml = answer ? `
👤 ${escapeHtml(answer)}
` : ''; - return `
  • ${escapeHtml(q.question)}${opts}${answerHtml}
  • `; + return `
  • ${escapeHtml(q.question)}${type}${opts}${answerHtml}
  • `; }) .join(''); const summary = diff --git a/packages/@n8n/instance-ai/evaluations/types.ts b/packages/@n8n/instance-ai/evaluations/types.ts index caba08ceb29..30a5be38405 100644 --- a/packages/@n8n/instance-ai/evaluations/types.ts +++ b/packages/@n8n/instance-ai/evaluations/types.ts @@ -448,6 +448,7 @@ export interface PlanTask { export interface AskUserQuestion { id: string; question: string; + type?: 'single' | 'multi' | 'text'; options?: string[]; } diff --git a/packages/@n8n/instance-ai/evaluations/utils/conversation-text.ts b/packages/@n8n/instance-ai/evaluations/utils/conversation-text.ts index 0839b130bb3..287b0e78600 100644 --- a/packages/@n8n/instance-ai/evaluations/utils/conversation-text.ts +++ b/packages/@n8n/instance-ai/evaluations/utils/conversation-text.ts @@ -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}`; diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md index 79254b563ba..67f30263bb0 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md @@ -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 diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/nodes.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/nodes.tool.test.ts index 8166425ca08..f9eb7270543 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/nodes.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/nodes.tool.test.ts @@ -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 { @@ -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(); diff --git a/packages/@n8n/instance-ai/src/tools/nodes.tool.ts b/packages/@n8n/instance-ai/src/tools/nodes.tool.ts index 99b03736fd6..7b206682511 100644 --- a/packages/@n8n/instance-ai/src/tools/nodes.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/nodes.tool.ts @@ -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( + context: InstanceAiContext, + node: T, + version?: number, +): Promise> { + 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) { +async function handleSuggested( + context: InstanceAiContext, + input: Extract, +) { const results: Array<{ category: string; description: string; patternHint: string; - suggestedNodes: Array<{ name: string; note?: string }>; + suggestedNodes: Array>; }> = []; 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); } diff --git a/packages/@n8n/instance-ai/src/tools/nodes/__tests__/setup-preference-null.test.ts b/packages/@n8n/instance-ai/src/tools/nodes/__tests__/setup-preference-null.test.ts new file mode 100644 index 00000000000..4b89da13441 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/nodes/__tests__/setup-preference-null.test.ts @@ -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, + }, + ], + }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/nodes/__tests__/setup-preference.test.ts b/packages/@n8n/instance-ai/src/tools/nodes/__tests__/setup-preference.test.ts new file mode 100644 index 00000000000..00ccb3fb930 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/nodes/__tests__/setup-preference.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json b/packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json new file mode 100644 index 00000000000..4f2203c0955 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json @@ -0,0 +1,1792 @@ +[ + { + "id": "actionNetworkApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "activeCampaignApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "acuitySchedulingApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "acuitySchedulingOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "adaloApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "affinityApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "agileCrmApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "airtableApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "airtableOAuth2Api", + "setupability": 0.45, + "popularity": 0.7 + }, + { + "id": "airtableTokenApi", + "setupability": 0.35, + "popularity": 0.7 + }, + { + "id": "airtopApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "alibabaCloudApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "amqp", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "anthropicApi", + "setupability": 0.3, + "popularity": 0.8 + }, + { + "id": "apiTemplateIoApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "asanaApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "asanaOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "autopilotApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "aws", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "awsAssumeRole", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "azureAiSearchApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "azureEntraCognitiveServicesOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "azureOpenAiApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "azureStorageOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "azureStorageSharedKeyApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "bambooHrApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "bannerbearApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "baserowApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "baserowTokenApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "bitbucketAccessTokenApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "bitbucketApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "bitlyApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "bitlyOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "bitwardenApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "boxOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "brandfetchApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "bubbleApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "calApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "calendlyApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "calendlyOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "chromaCloudApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "chromaSelfHostedApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "circleCiApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "ciscoWebexOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "clearbitApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "clickUpApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "clickUpOAuth2Api", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "clockifyApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "cloudflareApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "codaApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "cohereApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "contentfulApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "convertKitApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "copperApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "cortexApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "crypto", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "customerIoApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "databricksApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "databricksOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "deepLApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "deepSeekApi", + "setupability": 0.55, + "popularity": 0.6 + }, + { + "id": "demioApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "dhlApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "discordBotApi", + "setupability": 0.3, + "popularity": 0.5 + }, + { + "id": "discordOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "discordWebhookApi", + "setupability": null, + "popularity": 0.6 + }, + { + "id": "dropboxApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "dropboxOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "dropcontactApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "elasticsearchApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "emeliaApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "erpNextApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "eventbriteApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "eventbriteOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "facebookGraphApi", + "setupability": 0.15, + "popularity": 0.6 + }, + { + "id": "facebookGraphApiOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "facebookGraphAppApi", + "setupability": 0.25, + "popularity": 0.4 + }, + { + "id": "facebookGraphAppOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "facebookLeadAdsOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "figmaApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "figmaOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "fileMaker", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "flowApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "formIoApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "formstackApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "freshdeskApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "freshserviceApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "freshworksCrmApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "ftp", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "gSuiteAdminOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "getResponseApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "ghostAdminApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "ghostContentApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "gitPassword", + "setupability": null, + "popularity": 0 + }, + { + "id": "githubApi", + "setupability": 0.4, + "popularity": 0.5 + }, + { + "id": "githubAppApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "githubOAuth2Api", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "gitlabApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "gitlabOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "gmailOAuth2", + "setupability": 0.6, + "popularity": 1 + }, + { + "id": "goToWebinarOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "gongApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "gongOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "googleAdsOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "googleAnalyticsOAuth2", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "googleApi", + "setupability": 0.25, + "popularity": 0.7 + }, + { + "id": "googleBigQueryOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "googleBooksOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "googleBusinessProfileOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "googleCalendarOAuth2Api", + "setupability": 0.55, + "popularity": 0.9 + }, + { + "id": "googleChatOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "googleCloudNaturalLanguageOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "googleCloudStorageOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "googleContactsOAuth2Api", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "googleDocsOAuth2Api", + "setupability": 0.65, + "popularity": 0.7 + }, + { + "id": "googleDriveOAuth2Api", + "setupability": 0.65, + "popularity": 0.8 + }, + { + "id": "googleFirebaseCloudFirestoreOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "googleFirebaseRealtimeDatabaseOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "googlePalmApi", + "setupability": 0.5, + "popularity": 0.9 + }, + { + "id": "googlePerspectiveOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "googleSheetsOAuth2Api", + "setupability": 0.5, + "popularity": 1 + }, + { + "id": "googleSheetsTriggerOAuth2Api", + "setupability": 0.55, + "popularity": 0.8 + }, + { + "id": "googleSlidesOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "googleTasksOAuth2Api", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "googleTranslateOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "gotifyApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "grafanaApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "gristApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "groqApi", + "setupability": 0.65, + "popularity": 0.8 + }, + { + "id": "gumroadApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "gumroadOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "haloPSAApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "harvestApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "harvestOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "helpScoutOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "highLevelApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "highLevelOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "homeAssistantApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "httpBasicAuth", + "setupability": 0.4, + "popularity": 0.7 + }, + { + "id": "httpBearerAuth", + "setupability": 0.35, + "popularity": 0.6 + }, + { + "id": "httpCustomAuth", + "setupability": 0.3, + "popularity": 0.1 + }, + { + "id": "httpHeaderAuth", + "setupability": 0.4, + "popularity": 0.8 + }, + { + "id": "httpMultipleHeadersAuth", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "httpQueryAuth", + "setupability": 0.35, + "popularity": 0.1 + }, + { + "id": "hubspotApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "hubspotAppToken", + "setupability": 0.3, + "popularity": 0.4 + }, + { + "id": "hubspotDeveloperApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "hubspotOAuth2Api", + "setupability": null, + "popularity": 0.6 + }, + { + "id": "huggingFaceApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "humanticAiApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "hunterApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "imap", + "setupability": 0.4, + "popularity": 0.6 + }, + { + "id": "intercomApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "invoiceNinjaApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "iterableApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "jenkinsApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "jinaAiApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "jiraSoftwareCloudApi", + "setupability": 0.25, + "popularity": 0.5 + }, + { + "id": "jiraSoftwareCloudOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "jiraSoftwareServerApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "jiraSoftwareServerPatApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "jotFormApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "jwtAuth", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "kafka", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "keapOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "koBoToolboxApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "ldap", + "setupability": null, + "popularity": 0 + }, + { + "id": "lemlistApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "lemonadeApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "lineNotifyOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "linearApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "linearOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "lingvaNexApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "linkedInCommunityManagementOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "linkedInOAuth2Api", + "setupability": 0.45, + "popularity": 0.6 + }, + { + "id": "magento2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "mailcheckApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "mailchimpApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "mailchimpOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "mailerLiteApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "mailgunApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "mailjetEmailApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "mandrillApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "marketstackApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "matrixApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "mattermostApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "mauticApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "mauticOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "mcpOAuth2Api", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "mediumApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "messageBirdApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "metabaseApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "microsoftAgent365Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "microsoftAzureCosmosDbSharedKeyApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "microsoftDynamicsOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "microsoftEntraOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "microsoftEntraServicePrincipalApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "microsoftExcelOAuth2Api", + "setupability": 0.15, + "popularity": 0.5 + }, + { + "id": "microsoftGraphSecurityOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "microsoftOAuth2Api", + "setupability": 0.25, + "popularity": 0.6 + }, + { + "id": "microsoftOneDriveOAuth2Api", + "setupability": 0.35, + "popularity": 0.5 + }, + { + "id": "microsoftOutlookOAuth2Api", + "setupability": 0.4, + "popularity": 0.7 + }, + { + "id": "microsoftSharePointOAuth2Api", + "setupability": 0.25, + "popularity": 0.4 + }, + { + "id": "microsoftSql", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "microsoftTeamsOAuth2Api", + "setupability": 0.2, + "popularity": 0.5 + }, + { + "id": "microsoftToDoOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "milvusApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "mindeeInvoiceApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "mindeeReceiptApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "minimaxApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "mispApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "mistralCloudApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "mondayComApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "mondayComOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "mongoDb", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "moonshotApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "motorheadApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "mqtt", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "msg91Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "mySql", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "n8nApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "nasaApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "netlifyApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "nextCloudApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "nextCloudOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "nocoDb", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "nocoDbApiToken", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "notionApi", + "setupability": 0.3, + "popularity": 0.7 + }, + { + "id": "notionOAuth2Api", + "setupability": null, + "popularity": 0.6 + }, + { + "id": "npmApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "nvidiaApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "oAuth2Api", + "setupability": 0.2, + "popularity": 0.1 + }, + { + "id": "odooApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "odooApiKeyApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "oktaApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "ollamaApi", + "setupability": 0.05, + "popularity": 0.4 + }, + { + "id": "onfleetApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "openAiApi", + "setupability": 0.6, + "popularity": 0.9 + }, + { + "id": "openRouterApi", + "setupability": 0.55, + "popularity": 0.8 + }, + { + "id": "openWeatherMapApi", + "setupability": 0.15, + "popularity": 0.7 + }, + { + "id": "oracleDBApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "ouraApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "paddleApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "pagerDutyApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "pagerDutyOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "payPalApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "perplexityApi", + "setupability": 0.15, + "popularity": 0.4 + }, + { + "id": "phantombusterApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "philipsHueOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "pineconeApi", + "setupability": null, + "popularity": 0.6 + }, + { + "id": "pipedriveApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "pipedriveOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "plivoApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "postHogApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "postgres", + "setupability": 0.15, + "popularity": 0.7 + }, + { + "id": "pushbulletOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "pushcutApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "pushoverApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "qdrantApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "quickBooksOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "quickbaseApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "rabbitmq", + "setupability": null, + "popularity": 0 + }, + { + "id": "raindropOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "redditOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "redis", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "rocketchatApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "s3", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "salesforceJwtApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "salesforceOAuth2Api", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "salesmateApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "seaTableApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "searXngApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "securityScorecardApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "sendGridApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "sendInBlueApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "sendyApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "sentryIoApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "sentryIoOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "sentryIoServerApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "serpApi", + "setupability": 0.35, + "popularity": 0.4 + }, + { + "id": "serviceNowBasicApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "serviceNowOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "sftp", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "shopifyAccessTokenApi", + "setupability": 0.1, + "popularity": 0.4 + }, + { + "id": "shopifyApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "shopifyOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "slackApi", + "setupability": 0.2, + "popularity": 0.8 + }, + { + "id": "slackOAuth2Api", + "setupability": 0.4, + "popularity": 0.7 + }, + { + "id": "sms77Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "smtp", + "setupability": 0.2, + "popularity": 0.7 + }, + { + "id": "snowflake", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "snowflakeOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "splunkApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "spotifyOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "sshPassword", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "sshPrivateKey", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "stackbyApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "storyblokContentApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "storyblokManagementApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "strapiApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "strapiTokenApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "stravaOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "stripeApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "supabaseApi", + "setupability": 0.4, + "popularity": 0.8 + }, + { + "id": "surveyMonkeyApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "surveyMonkeyOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "syncroMspApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "taigaApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "tapfiliateApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "telegramApi", + "setupability": 0.55, + "popularity": 1 + }, + { + "id": "theHiveApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "theHiveProjectApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "todoistApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "todoistOAuth2Api", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "togglApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "totpApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "trelloApi", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "trelloOAuth1Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "twilioApi", + "setupability": 0.25, + "popularity": 0.6 + }, + { + "id": "twistOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "twitterOAuth1Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "twitterOAuth2Api", + "setupability": 0.25, + "popularity": 0.5 + }, + { + "id": "typeformApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "typeformOAuth2Api", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "unleashedSoftwareApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "upleadApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "uprocApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "uptimeRobotApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "urlScanIoApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "venafiTlsProtectCloudApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "venafiTlsProtectDatacenterApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "vercelAiGatewayApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "vonageApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "weaviateApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "webflowOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "wekanApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "whatsAppApi", + "setupability": 0.15, + "popularity": 0.8 + }, + { + "id": "whatsAppTriggerApi", + "setupability": 0.15, + "popularity": 0.7 + }, + { + "id": "wiseApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "wolframAlphaApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "wooCommerceApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "wordpressApi", + "setupability": 0.35, + "popularity": 0.5 + }, + { + "id": "wordpressOAuth2Api", + "setupability": null, + "popularity": 0 + }, + { + "id": "workableApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "xAiApi", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "xataApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "xeroOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "youTubeOAuth2Api", + "setupability": 0.45, + "popularity": 0.6 + }, + { + "id": "yourlsApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "zammadBasicAuthApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "zammadTokenAuthApi", + "setupability": null, + "popularity": 0.1 + }, + { + "id": "zendeskApi", + "setupability": null, + "popularity": 0.4 + }, + { + "id": "zendeskOAuth2Api", + "setupability": null, + "popularity": 0.3 + }, + { + "id": "zepApi", + "setupability": null, + "popularity": 0 + }, + { + "id": "zohoOAuth2Api", + "setupability": null, + "popularity": 0.5 + }, + { + "id": "zoomApi", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "zoomOAuth2Api", + "setupability": null, + "popularity": 0.2 + }, + { + "id": "zulipApi", + "setupability": null, + "popularity": 0 + } +] diff --git a/packages/@n8n/instance-ai/src/tools/nodes/setup-preference.ts b/packages/@n8n/instance-ai/src/tools/nodes/setup-preference.ts new file mode 100644 index 00000000000..e1cbd895e55 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/nodes/setup-preference.ts @@ -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 & { + setupPreference?: SetupPreference[]; +}; + +const setupPreferences = new Map(); + +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( + node: T, + credentialTypes: readonly string[], +): NodeWithSetupPreference { + 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; +} diff --git a/packages/@n8n/instance-ai/tsconfig.build.json b/packages/@n8n/instance-ai/tsconfig.build.json index 061bbe7166d..25fffe6f06e 100644 --- a/packages/@n8n/instance-ai/tsconfig.build.json +++ b/packages/@n8n/instance-ai/tsconfig.build.json @@ -5,6 +5,6 @@ "rootDir": "src", "outDir": "dist" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.json"], "exclude": ["src/**/__tests__/**", "src/**/test-utils/**", "**/*.test.ts"] }