mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-29 12:05:04 +02:00
feat(agents): expose ChatIntegrationRegistry via API and consume in FE
- Add display metadata fields (displayLabel, displayIcon, displayHelpText, displayConnectedText) to AgentChatIntegration abstract class and all three platform implementations (Slack, Telegram, Linear) - Add ChatIntegrationDescriptor DTO to @n8n/api-types - Add GET /catalog/integrations endpoint on AgentsController backed by AgentsService.listChatIntegrations() - Add listAgentIntegrations() API client and useAgentIntegrationsCatalog composable (module-scoped singleton with dedup in-flight logic) - Replace hardcoded KNOWN_TRIGGER_TYPES in AgentBuilderView with dynamic catalog fetch; add tests for the new composable and update view test mock Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8c1181d838
commit
282004508d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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<Agent> {
|
||||
// New agents start with no instructions so the home screen routes the
|
||||
// first user message to the builder (/build) instead of to the chat
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
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<unknown> {
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
'agents.builder.chatMode.build': 'Build',
|
||||
|
|
|
|||
|
|
@ -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<typeof fakeIntegrations>((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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ChatIntegrationDescriptor[]> => {
|
||||
return await makeRestApiRequest<ChatIntegrationDescriptor[]>(
|
||||
context,
|
||||
'GET',
|
||||
`/projects/${projectId}/agents/v2/catalog/integrations`,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<ChatIntegrationDescriptor[] | null>(null);
|
||||
let inFlight: Promise<ChatIntegrationDescriptor[]> | null = null;
|
||||
|
||||
export function useAgentIntegrationsCatalog() {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
async function ensureLoaded(projectId: string): Promise<ChatIntegrationDescriptor[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<AgentJsonConfig | null>(null);
|
||||
const connectedTriggers = ref<string[]>([]);
|
||||
|
||||
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;
|
||||
})();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user