diff --git a/packages/@n8n/api-types/src/agents.ts b/packages/@n8n/api-types/src/agents.ts index f9ad0d3ce5d..17fe61b9a12 100644 --- a/packages/@n8n/api-types/src/agents.ts +++ b/packages/@n8n/api-types/src/agents.ts @@ -13,6 +13,19 @@ import { SCHEDULE_TRIGGER_NODE_TYPE, } from 'n8n-workflow'; +/** + * Describes a chat platform integration that agents can connect to. + * Source of truth: the backend `ChatIntegrationRegistry`. + */ +export interface ChatIntegrationDescriptor { + type: string; + label: string; + icon: string; + credentialTypes: string[]; + helpText: string; + connectedText: string; +} + /** * Node types a workflow can use as its trigger to be eligible as an agent * tool. Single source of truth for both the backend compatibility check diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 452ba26e179..770d8775a92 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -9,6 +9,11 @@ import { AgentsService, chatThreadId } from '../agents.service'; import type { AgentPublishedVersion } from '../entities/agent-published-version.entity'; import type { Agent } from '../entities/agent.entity'; import { ChatIntegrationService } from '../integrations/chat-integration.service'; +import { + AgentChatIntegration, + ChatIntegrationRegistry, + type AgentChatIntegrationContext, +} from '../integrations/agent-chat-integration'; import type { N8nMemory } from '../integrations/n8n-memory'; import type { AgentJsonConfig } from '../json-config/agent-json-config'; import type { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository'; @@ -297,6 +302,49 @@ describe('AgentsService', () => { }); }); + describe('listChatIntegrations', () => { + class TestIntegration extends AgentChatIntegration { + readonly type = 'test-platform'; + readonly credentialTypes = ['testApi']; + readonly displayLabel = 'Test Platform'; + readonly displayIcon = 'circle'; + readonly displayHelpText = 'Connect a Test credential.'; + readonly displayConnectedText = 'Connected to Test.'; + async createAdapter(_ctx: AgentChatIntegrationContext): Promise { + return {}; + } + } + + it('returns one descriptor per registered integration', () => { + const registry = new ChatIntegrationRegistry(); + registry.register(new TestIntegration()); + Container.set(ChatIntegrationRegistry, registry); + + const result = service.listChatIntegrations(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'test-platform', + label: 'Test Platform', + icon: 'circle', + credentialTypes: ['testApi'], + helpText: 'Connect a Test credential.', + connectedText: 'Connected to Test.', + }); + }); + + it('returns an empty array when no integrations are registered', () => { + const registry = new ChatIntegrationRegistry(); + Container.set(ChatIntegrationRegistry, registry); + + expect(service.listChatIntegrations()).toEqual([]); + }); + + afterEach(() => { + Container.reset(); + }); + }); + describe('delete — chat cleanup', () => { it('removes the test-chat thread + messages after removing the agent', async () => { const agent = makeAgent(); diff --git a/packages/cli/src/modules/agents/agents.controller.ts b/packages/cli/src/modules/agents/agents.controller.ts index 3c9f2eb962a..3f8d53f21c1 100644 --- a/packages/cli/src/modules/agents/agents.controller.ts +++ b/packages/cli/src/modules/agents/agents.controller.ts @@ -1,5 +1,5 @@ import type { AgentMessage, StreamChunk } from '@n8n/agents'; -import type { AgentPersistedMessageDto } from '@n8n/api-types'; +import type { AgentPersistedMessageDto, ChatIntegrationDescriptor } from '@n8n/api-types'; import { AuthenticatedRequest } from '@n8n/db'; import { Body, Delete, Get, Param, Patch, Post, Put, RestController } from '@n8n/decorators'; import { randomUUID } from 'crypto'; @@ -140,6 +140,11 @@ export class AgentsController { return await fetchProviderCatalog(); } + @Get('/catalog/integrations') + listIntegrations(): ChatIntegrationDescriptor[] { + return this.agentsService.listChatIntegrations(); + } + @Get('/threads') async listThreads( req: AuthenticatedRequest< diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index b3fe647bea7..2c18c5eed23 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -5,6 +5,7 @@ import type { StreamChunk, ToolDescriptor, } from '@n8n/agents'; +import type { ChatIntegrationDescriptor } from '@n8n/api-types'; import * as agents from '@n8n/agents'; import { Logger } from '@n8n/backend-common'; import { Time } from '@n8n/constants'; @@ -146,6 +147,23 @@ export class AgentsService { private readonly agentPublishedVersionRepository: AgentPublishedVersionRepository, ) {} + /** + * Return the list of registered chat platform integrations with their + * FE display metadata. Used by `GET /agents/integrations`. + */ + listChatIntegrations(): ChatIntegrationDescriptor[] { + return Container.get(ChatIntegrationRegistry) + .list() + .map((i) => ({ + type: i.type, + label: i.displayLabel, + icon: i.displayIcon, + credentialTypes: i.credentialTypes, + helpText: i.displayHelpText, + connectedText: i.displayConnectedText, + })); + } + async create(projectId: string, name: string): Promise { // New agents start with no instructions so the home screen routes the // first user message to the builder (/build) instead of to the chat diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index 2de75a3c217..e96c25c9891 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -70,6 +70,10 @@ class BufferingTestIntegration extends AgentChatIntegration { readonly credentialTypes: string[] = []; readonly supportedComponents: string[] = []; readonly description = ''; + readonly displayLabel = 'Test Buffered'; + readonly displayIcon = 'circle'; + readonly displayHelpText = ''; + readonly displayConnectedText = ''; readonly disableStreaming = true; async createAdapter(_ctx: AgentChatIntegrationContext): Promise { return {}; @@ -81,6 +85,10 @@ class StreamingTestIntegration extends AgentChatIntegration { readonly credentialTypes: string[] = []; readonly supportedComponents: string[] = []; readonly description = ''; + readonly displayLabel = 'Test Streaming'; + readonly displayIcon = 'circle'; + readonly displayHelpText = ''; + readonly displayConnectedText = ''; async createAdapter(_ctx: AgentChatIntegrationContext): Promise { return {}; } diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts index 477508851be..4214aac182e 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts @@ -28,6 +28,23 @@ export abstract class AgentChatIntegration { /** Credential types accepted by the frontend selector. */ abstract readonly credentialTypes: string[]; + // --------------------------------------------------------------------------- + // FE display metadata — shown in the trigger-picker and integration cards. + // These are user-facing; keep them distinct from `description` (LLM-facing). + // --------------------------------------------------------------------------- + + /** Human-readable label shown in UI. */ + abstract readonly displayLabel: string; + + /** Lucide icon name (from the shared icon set) for the integration card. */ + abstract readonly displayIcon: string; + + /** Help text shown in the trigger-picker before the user connects. */ + abstract readonly displayHelpText: string; + + /** Confirmation text shown after the integration is connected. */ + abstract readonly displayConnectedText: string; + /** * Component types this platform supports in rich_interaction cards. * Omit to signal that the platform has no rich_interaction surface — the diff --git a/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts index f66f7e415c5..693d84beef1 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts @@ -24,6 +24,16 @@ export class LinearIntegration extends AgentChatIntegration { readonly credentialTypes = ['linearApi', 'linearOAuth2Api']; + readonly displayLabel = 'Linear'; + + readonly displayIcon = 'list-checks'; + + readonly displayHelpText = + 'Connect a Linear API credential to let this agent respond to comments in Linear issues. ' + + 'Point a Linear webhook at the URL below and paste its signing secret into the credential.'; + + readonly displayConnectedText = 'Your agent is connected to Linear and can reply to @-mentions.'; + constructor(private readonly logger: Logger) { super(); } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts index ca8a880c9a7..60a4e3b960e 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts @@ -16,6 +16,15 @@ export class SlackIntegration extends AgentChatIntegration { readonly credentialTypes = ['slackApi', 'slackOAuth2Api']; + readonly displayLabel = 'Slack'; + + readonly displayIcon = 'hashtag'; + + readonly displayHelpText = + 'Connect a Slack bot credential to allow this agent to receive and respond to Slack messages.'; + + readonly displayConnectedText = 'Your agent is connected to Slack and can receive messages.'; + readonly supportedComponents = [ 'section', 'button', diff --git a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts index 6a9f3d6b655..b02d7ce9daf 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts @@ -30,6 +30,15 @@ export class TelegramIntegration extends AgentChatIntegration { readonly credentialTypes = ['telegramApi']; + readonly displayLabel = 'Telegram'; + + readonly displayIcon = 'paper-plane'; + + readonly displayHelpText = + 'Connect a Telegram bot credential to allow this agent to receive and respond to Telegram messages.'; + + readonly displayConnectedText = 'Your agent is connected to Telegram and can receive messages.'; + readonly supportedComponents = ['section', 'button', 'divider', 'fields']; readonly description = diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 81b9752e2b9..36fcf64e503 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -103,6 +103,13 @@ vi.mock('../agentSessions.store', () => ({ }), })); +vi.mock('../composables/useAgentIntegrationsCatalog', () => ({ + useAgentIntegrationsCatalog: () => ({ + catalog: { value: [] }, + ensureLoaded: vi.fn().mockResolvedValue([]), + }), +})); + const baseTextFn = (key: string) => { const map: Record = { 'agents.builder.chatMode.build': 'Build', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentIntegrationsCatalog.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentIntegrationsCatalog.test.ts new file mode 100644 index 00000000000..25f185914e1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentIntegrationsCatalog.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockListAgentIntegrations = vi.fn(); + +vi.mock('../composables/useAgentApi', () => ({ + listAgentIntegrations: mockListAgentIntegrations, +})); + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }), +})); + +describe('useAgentIntegrationsCatalog', () => { + const projectId = 'p1'; + const fakeIntegrations = [ + { + type: 'slack', + label: 'Slack', + icon: 'hashtag', + credentialTypes: ['slackApi'], + helpText: 'Connect Slack.', + connectedText: 'Connected to Slack.', + }, + ]; + + beforeEach(async () => { + vi.clearAllMocks(); + // Reset module state between tests by re-importing a fresh module. + vi.resetModules(); + }); + + it('fetches integrations on first call and returns them', async () => { + mockListAgentIntegrations.mockResolvedValue(fakeIntegrations); + const { useAgentIntegrationsCatalog } = await import( + '../composables/useAgentIntegrationsCatalog' + ); + const { ensureLoaded } = useAgentIntegrationsCatalog(); + + const result = await ensureLoaded(projectId); + + expect(mockListAgentIntegrations).toHaveBeenCalledTimes(1); + expect(result).toEqual(fakeIntegrations); + }); + + it('does not re-fetch on second call (cache hit)', async () => { + mockListAgentIntegrations.mockResolvedValue(fakeIntegrations); + const { useAgentIntegrationsCatalog } = await import( + '../composables/useAgentIntegrationsCatalog' + ); + const { ensureLoaded } = useAgentIntegrationsCatalog(); + + await ensureLoaded(projectId); + const second = await ensureLoaded(projectId); + + expect(mockListAgentIntegrations).toHaveBeenCalledTimes(1); + expect(second).toEqual(fakeIntegrations); + }); + + it('deduplicates concurrent in-flight requests', async () => { + let resolve!: (v: typeof fakeIntegrations) => void; + const deferred = new Promise((res) => { + resolve = res; + }); + mockListAgentIntegrations.mockReturnValue(deferred); + + const { useAgentIntegrationsCatalog } = await import( + '../composables/useAgentIntegrationsCatalog' + ); + const { ensureLoaded } = useAgentIntegrationsCatalog(); + + const p1 = ensureLoaded(projectId); + const p2 = ensureLoaded(projectId); + resolve(fakeIntegrations); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(mockListAgentIntegrations).toHaveBeenCalledTimes(1); + expect(r1).toEqual(fakeIntegrations); + expect(r2).toEqual(fakeIntegrations); + }); + + it('clears inFlight and re-throws on error', async () => { + mockListAgentIntegrations.mockRejectedValueOnce(new Error('network error')); + const { useAgentIntegrationsCatalog } = await import( + '../composables/useAgentIntegrationsCatalog' + ); + const { ensureLoaded } = useAgentIntegrationsCatalog(); + + await expect(ensureLoaded(projectId)).rejects.toThrow('network error'); + + // After failure, a subsequent call should try again. + mockListAgentIntegrations.mockResolvedValue(fakeIntegrations); + const result = await ensureLoaded(projectId); + expect(mockListAgentIntegrations).toHaveBeenCalledTimes(2); + expect(result).toEqual(fakeIntegrations); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts index 414f30f1906..3590f22bbdd 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts @@ -1,4 +1,4 @@ -import type { AgentPersistedMessageDto } from '@n8n/api-types'; +import type { AgentPersistedMessageDto, ChatIntegrationDescriptor } from '@n8n/api-types'; import { makeRestApiRequest } from '@n8n/rest-api-client'; import type { IRestApiContext } from '@n8n/rest-api-client'; import type { AgentResource, AgentJsonConfig } from '../types'; @@ -292,3 +292,14 @@ export const deleteCustomTool = async ( `/projects/${projectId}/agents/v2/${agentId}/tools/${toolId}`, ); }; + +export const listAgentIntegrations = async ( + context: IRestApiContext, + projectId: string, +): Promise => { + return await makeRestApiRequest( + context, + 'GET', + `/projects/${projectId}/agents/v2/catalog/integrations`, + ); +}; diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationsCatalog.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationsCatalog.ts new file mode 100644 index 00000000000..b250a27e907 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationsCatalog.ts @@ -0,0 +1,33 @@ +import { ref } from 'vue'; +import type { ChatIntegrationDescriptor } from '@n8n/api-types'; +import { useRootStore } from '@n8n/stores/useRootStore'; +import { listAgentIntegrations } from './useAgentApi'; + +const catalog = ref(null); +let inFlight: Promise | null = null; + +export function useAgentIntegrationsCatalog() { + const rootStore = useRootStore(); + + async function ensureLoaded(projectId: string): Promise { + if (catalog.value) return catalog.value; + if (!inFlight) { + inFlight = listAgentIntegrations(rootStore.restApiContext, projectId) + .then((list) => { + catalog.value = list; + inFlight = null; + return list; + }) + .catch((err: unknown) => { + inFlight = null; + throw err; + }); + } + return await inFlight; + } + + return { + catalog, + ensureLoaded, + }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index 73723261ec5..47e1f10a07b 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -16,6 +16,7 @@ import { useToast } from '@/app/composables/useToast'; import { MODAL_CONFIRM, MODAL_CANCEL, getDebounceTime } from '@/app/constants'; import { deepCopy } from 'n8n-workflow'; import { getAgent, deleteAgent, publishAgent } from '../composables/useAgentApi'; +import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog'; import type { AgentResource, AgentJsonConfig, AgentJsonToolRef } from '../types'; import { deriveAgentStatus } from '../composables/agentTelemetry.utils'; import { useAgentBuilderTelemetry } from '../composables/useAgentBuilderTelemetry'; @@ -87,6 +88,8 @@ const { config, fetchConfig, updateConfig } = useAgentConfig(); const localConfig = ref(null); const connectedTriggers = ref([]); +const { ensureLoaded: ensureIntegrationsCatalog } = useAgentIntegrationsCatalog(); + const builderTelemetry = useAgentBuilderTelemetry({ agentId, projectId, @@ -113,9 +116,6 @@ watch( { immediate: true }, ); -// Keep in sync with AgentIntegrationsPanel.integrationConfigs -const KNOWN_TRIGGER_TYPES = ['slack', 'telegram'] as const; - async function fetchAgent() { const data = await getAgent(rootStore.restApiContext, projectId.value, agentId.value); agent.value = data; @@ -397,7 +397,9 @@ async function initialize() { void (async () => { // Non-fatal — on failure, leave connectedTriggers empty; the sidebar emit // will correct it once the user expands the Triggers section. - const connected = await builderTelemetry.fetchInitialTriggersBaseline(KNOWN_TRIGGER_TYPES); + const integrations = await ensureIntegrationsCatalog(projectId.value).catch(() => []); + const triggerTypes = integrations.map((i) => i.type); + const connected = await builderTelemetry.fetchInitialTriggersBaseline(triggerTypes); if (connected) connectedTriggers.value = connected; })();