From b7c92e07c765a316bb3e9af2821052aa6b9aeaec Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Mon, 15 Dec 2025 15:40:13 +0100 Subject: [PATCH] feat(core, editor): Better distinguish personal and workflow agents in chat (no-changelog) (#23032) Co-authored-by: Jaakko Husso --- packages/@n8n/api-types/src/chat-hub.ts | 20 + packages/@n8n/api-types/src/index.ts | 2 + .../@n8n/backend-test-utils/src/test-db.ts | 1 + .../1765788427674-AddIconToAgentTable.ts | 15 + .../@n8n/db/src/migrations/mysqldb/index.ts | 2 + .../1765804780000-ConvertAgentIdToUuid.ts | 28 ++ .../db/src/migrations/postgresdb/index.ts | 4 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + packages/@n8n/utils/src/string/truncate.ts | 14 +- ...hat-hub-models.service.integration.test.ts | 400 ++++++++++++++++++ .../chat-hub.service.integration.test.ts | 163 ++++++- .../modules/chat-hub/chat-hub-agent.entity.ts | 10 +- .../chat-hub/chat-hub-agent.service.ts | 59 ++- .../chat-hub/chat-hub-message.entity.ts | 2 +- .../chat-hub/chat-hub-session.entity.ts | 10 +- .../chat-hub/chat-hub.models.service.ts | 137 +++--- .../src/modules/chat-hub/chat-hub.service.ts | 60 +-- .../chat-hub/chat-session.repository.ts | 19 +- .../src/components/N8nIcon/icons.ts | 2 + .../src/components/N8nIconPicker/constants.ts | 1 + .../NavigationDropdown.vue | 59 ++- .../frontend/@n8n/i18n/src/locales/en.json | 28 +- ...ntsView.vue => ChatPersonalAgentsView.vue} | 136 ++---- .../src/features/ai/chatHub/ChatView.vue | 48 ++- .../ai/chatHub/ChatWorkflowAgentsView.vue | 156 +++++++ .../src/features/ai/chatHub/chat.store.ts | 72 ++-- .../src/features/ai/chatHub/chat.types.ts | 18 +- .../src/features/ai/chatHub/chat.utils.ts | 26 +- .../chatHub/components/AgentEditorModal.vue | 54 ++- .../ai/chatHub/components/ChatAgentAvatar.vue | 70 ++- .../ai/chatHub/components/ChatAgentCard.vue | 28 +- .../components/ChatAgentSearchSort.vue | 87 ++++ .../ai/chatHub/components/ChatMessage.vue | 9 +- .../components/ChatSessionMenuItem.vue | 2 +- .../chatHub/components/ChatSidebarContent.vue | 36 +- .../ai/chatHub/components/ModelSelector.vue | 75 +++- .../chatHub/components/SkeletonAgentCard.vue | 73 ++++ .../src/features/ai/chatHub/constants.ts | 3 +- .../features/ai/chatHub/module.descriptor.ts | 30 +- .../commandBar/composables/useCommandBar.ts | 6 +- 40 files changed, 1574 insertions(+), 393 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1765788427674-AddIconToAgentTable.ts create mode 100644 packages/@n8n/db/src/migrations/postgresdb/1765804780000-ConvertAgentIdToUuid.ts create mode 100644 packages/cli/src/modules/chat-hub/__tests__/chat-hub-models.service.integration.test.ts rename packages/frontend/editor-ui/src/features/ai/chatHub/{ChatAgentsView.vue => ChatPersonalAgentsView.vue} (60%) create mode 100644 packages/frontend/editor-ui/src/features/ai/chatHub/ChatWorkflowAgentsView.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatAgentSearchSort.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 009727700d9..a68adda3d69 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -29,6 +29,21 @@ export const chatHubLLMProviderSchema = z.enum([ ]); export type ChatHubLLMProvider = z.infer; +/** + * Schema for icon or emoji representation + */ +export const agentIconOrEmojiSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('icon'), + value: z.string(), + }), + z.object({ + type: z.literal('emoji'), + value: z.string(), + }), +]); +export type AgentIconOrEmoji = z.infer; + export const chatHubProviderSchema = z.enum([ ...chatHubLLMProviderSchema.options, 'n8n', @@ -222,6 +237,7 @@ export interface ChatModelDto { model: ChatHubConversationModel; name: string; description: string | null; + icon: AgentIconOrEmoji | null; updatedAt: string | null; createdAt: string | null; metadata: ChatModelMetadataDto; @@ -363,6 +379,7 @@ export interface ChatHubSessionDto { workflowId: string | null; agentId: string | null; agentName: string; + agentIcon: AgentIconOrEmoji | null; createdAt: string; updatedAt: string; tools: INode[]; @@ -414,6 +431,7 @@ export interface ChatHubAgentDto { id: string; name: string; description: string | null; + icon: AgentIconOrEmoji | null; systemPrompt: string; ownerId: string; credentialId: string | null; @@ -427,6 +445,7 @@ export interface ChatHubAgentDto { export class ChatHubCreateAgentRequest extends Z.class({ name: z.string().min(1).max(128), description: z.string().max(512).optional(), + icon: agentIconOrEmojiSchema, systemPrompt: z.string().min(1), credentialId: z.string(), provider: chatHubLLMProviderSchema, @@ -437,6 +456,7 @@ export class ChatHubCreateAgentRequest extends Z.class({ export class ChatHubUpdateAgentRequest extends Z.class({ name: z.string().min(1).max(128).optional(), description: z.string().max(512).optional(), + icon: agentIconOrEmojiSchema.optional(), systemPrompt: z.string().min(1).optional(), credentialId: z.string().optional(), provider: chatHubProviderSchema.optional(), diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 8a118c71f2f..3eb56774180 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -46,6 +46,8 @@ export { type ChatHubAgentDto, ChatHubCreateAgentRequest, ChatHubUpdateAgentRequest, + type AgentIconOrEmoji, + agentIconOrEmojiSchema, type EnrichedStructuredChunk, type ChatHubAgentTool, UpdateChatSettingsRequest, diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index afe8b6297e8..e7d1e4b35e9 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -83,6 +83,7 @@ type EntityName = | 'DataTableColumn' | 'ChatHubSession' | 'ChatHubMessage' + | 'ChatHubAgent' | 'OAuthClient' | 'AuthorizationCode' | 'AccessToken' diff --git a/packages/@n8n/db/src/migrations/common/1765788427674-AddIconToAgentTable.ts b/packages/@n8n/db/src/migrations/common/1765788427674-AddIconToAgentTable.ts new file mode 100644 index 00000000000..5052ea85c1e --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1765788427674-AddIconToAgentTable.ts @@ -0,0 +1,15 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = 'chat_hub_agents'; + +export class AddIconToAgentTable1765788427674 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + // Add icon column to agents table (nullable) + await addColumns(table, [column('icon').json]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + // Drop icon column + await dropColumns(table, ['icon']); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index eabf1f7a972..92ce829841a 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -125,6 +125,7 @@ import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/176 import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable'; import { BackfillMissingWorkflowHistoryRecords1765448186933 } from '../common/1765448186933-BackfillMissingWorkflowHistoryRecords'; import { AddResolvableFieldsToCredentials1765459448000 } from '../common/1765459448000-AddResolvableFieldsToCredentials'; +import { AddIconToAgentTable1765788427674 } from '../common/1765788427674-AddIconToAgentTable'; import type { Migration } from '../migration-types'; export const mysqlMigrations: Migration[] = [ @@ -255,4 +256,5 @@ export const mysqlMigrations: Migration[] = [ AddDynamicCredentialEntryTable1764689388394, BackfillMissingWorkflowHistoryRecords1765448186933, AddResolvableFieldsToCredentials1765459448000, + AddIconToAgentTable1765788427674, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/1765804780000-ConvertAgentIdToUuid.ts b/packages/@n8n/db/src/migrations/postgresdb/1765804780000-ConvertAgentIdToUuid.ts new file mode 100644 index 00000000000..288d1777eb5 --- /dev/null +++ b/packages/@n8n/db/src/migrations/postgresdb/1765804780000-ConvertAgentIdToUuid.ts @@ -0,0 +1,28 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = { + sessions: 'chat_hub_sessions', + messages: 'chat_hub_messages', +} as const; + +export class ConvertAgentIdToUuid1765804780000 implements ReversibleMigration { + async up({ runQuery, escape }: MigrationContext) { + // Convert agentId from varchar(36) to uuid to match agents.id type + await runQuery( + `ALTER TABLE ${escape.tableName(table.sessions)} ALTER COLUMN "agentId" TYPE uuid USING "agentId"::uuid`, + ); + await runQuery( + `ALTER TABLE ${escape.tableName(table.messages)} ALTER COLUMN "agentId" TYPE uuid USING "agentId"::uuid`, + ); + } + + async down({ runQuery, escape }: MigrationContext) { + // Revert agentId from uuid back to varchar(36) + await runQuery( + `ALTER TABLE ${escape.tableName(table.sessions)} ALTER COLUMN "agentId" TYPE varchar(36)`, + ); + await runQuery( + `ALTER TABLE ${escape.tableName(table.messages)} ALTER COLUMN "agentId" TYPE varchar(36)`, + ); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 2e04f8d78b5..ebca1b497f7 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -46,6 +46,7 @@ import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddPro import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn'; import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson'; import { ChangeDefaultForIdInUserTable1762771264000 } from './1762771264000-ChangeDefaultForIdInUserTable'; +import { ConvertAgentIdToUuid1765804780000 } from './1765804780000-ConvertAgentIdToUuid'; import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities'; import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; @@ -125,6 +126,7 @@ import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/176 import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable'; import { BackfillMissingWorkflowHistoryRecords1765448186933 } from '../common/1765448186933-BackfillMissingWorkflowHistoryRecords'; import { AddResolvableFieldsToCredentials1765459448000 } from '../common/1765459448000-AddResolvableFieldsToCredentials'; +import { AddIconToAgentTable1765788427674 } from '../common/1765788427674-AddIconToAgentTable'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -255,4 +257,6 @@ export const postgresMigrations: Migration[] = [ AddDynamicCredentialEntryTable1764689388394, BackfillMissingWorkflowHistoryRecords1765448186933, AddResolvableFieldsToCredentials1765459448000, + AddIconToAgentTable1765788427674, + ConvertAgentIdToUuid1765804780000, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 922449afaaf..226599dda03 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -121,6 +121,7 @@ import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/176416 import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable'; import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable'; import { BackfillMissingWorkflowHistoryRecords1765448186933 } from '../common/1765448186933-BackfillMissingWorkflowHistoryRecords'; +import { AddIconToAgentTable1765788427674 } from '../common/1765788427674-AddIconToAgentTable'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -247,6 +248,7 @@ const sqliteMigrations: Migration[] = [ AddDynamicCredentialEntryTable1764689388394, BackfillMissingWorkflowHistoryRecords1765448186933, AddResolvableFieldsToCredentials1764689448000, + AddIconToAgentTable1765788427674, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/utils/src/string/truncate.ts b/packages/@n8n/utils/src/string/truncate.ts index cc1d2024638..8aafb250c59 100644 --- a/packages/@n8n/utils/src/string/truncate.ts +++ b/packages/@n8n/utils/src/string/truncate.ts @@ -7,7 +7,11 @@ export const truncate = (text: string, length = 30): string => * - Remove chars just before the last word, as long as the last word is under 15 chars * - Otherwise preserve the last 5 chars of the name and remove chars before that */ -export function truncateBeforeLast(text: string, maxLength: number): string { +export function truncateBeforeLast( + text: string, + maxLength: number, + lastCharsLength: number = 5, +): string { const chars: string[] = []; const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); @@ -38,7 +42,13 @@ export function truncateBeforeLast(text: string, maxLength: number): string { } } + if (lastCharsLength < 1) { + return chars.slice(0, maxLength).join('') + ellipsis; + } + return ( - chars.slice(0, maxLength - 5 - ellipsisLength).join('') + ellipsis + chars.slice(-5).join('') + chars.slice(0, maxLength - lastCharsLength - ellipsisLength).join('') + + ellipsis + + chars.slice(-lastCharsLength).join('') ); } diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-models.service.integration.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-models.service.integration.test.ts new file mode 100644 index 00000000000..3112ceb6632 --- /dev/null +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-models.service.integration.test.ts @@ -0,0 +1,400 @@ +import { + createActiveWorkflow, + createWorkflow, + mockInstance, + testDb, + testModules, +} from '@n8n/backend-test-utils'; +import assert from 'assert'; +import type { User } from '@n8n/db'; +import { ProjectRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { BinaryDataService } from 'n8n-core'; +import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; +import { v4 as uuid } from 'uuid'; + +import { createMember } from '@test-integration/db/users'; + +import { ChatHubModelsService } from '../chat-hub.models.service'; + +mockInstance(BinaryDataService); + +beforeAll(async () => { + await testModules.loadModules(['chat-hub']); + await testDb.init(); +}); + +beforeEach(async () => { + await testDb.truncate(['WorkflowEntity']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +const emptyCredentialIds = { + openai: null, + anthropic: null, + google: null, + azureOpenAi: null, + azureEntraId: null, + ollama: null, + awsBedrock: null, + vercelAiGateway: null, + xAiGrok: null, + groq: null, + openRouter: null, + deepSeek: null, + cohere: null, + mistralCloud: null, +}; + +describe('ChatHubModelsService', () => { + let chatHubModelsService: ChatHubModelsService; + let projectRepository: ProjectRepository; + let member: User; + + beforeAll(() => { + chatHubModelsService = Container.get(ChatHubModelsService); + projectRepository = Container.get(ProjectRepository); + }); + + beforeEach(async () => { + member = await createMember(); + }); + + describe('getModels', () => { + describe('n8n workflow agents', () => { + it('should return empty models when user has no workflows', async () => { + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n).toBeDefined(); + expect(result.n8n.models).toEqual([]); + }); + + it('should return workflow as model when user has active workflow with chat trigger', async () => { + const workflowName = 'Test Agent Workflow'; + const agentName = 'Custom Agent Name'; + const agentDescription = 'This is a test agent'; + + await createActiveWorkflow( + { + name: workflowName, + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + agentName, + agentDescription, + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n).toBeDefined(); + expect(result.n8n.models).toHaveLength(1); + + const model = result.n8n.models[0]; + expect(model.name).toBe(agentName); + expect(model.description).toBe(agentDescription); + expect(model.model.provider).toBe('n8n'); + assert(model.model.provider === 'n8n'); + expect(model.model.workflowId).toBeDefined(); + expect(model.metadata.available).toBe(true); + }); + + it('should use workflow name when agentName is not provided', async () => { + const workflowName = 'Test Workflow'; + + await createActiveWorkflow( + { + name: workflowName, + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toHaveLength(1); + expect(result.n8n.models[0].name).toBe(workflowName); + }); + + it('should not return workflow when it is not active', async () => { + await createWorkflow( + { + name: 'Inactive Workflow', + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toEqual([]); + }); + + it('should not return workflow when availableInChat is false', async () => { + await createActiveWorkflow( + { + name: 'Not Available Workflow', + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: false, + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toEqual([]); + }); + + it('should not return workflow without chat trigger node', async () => { + await createActiveWorkflow( + { + name: 'Workflow Without Chat Trigger', + nodes: [ + { + id: uuid(), + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toEqual([]); + }); + + it('should return multiple workflow agents', async () => { + await createActiveWorkflow( + { + name: 'Agent 1', + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + agentName: 'First Agent', + }, + }, + ], + connections: {}, + }, + member, + ); + + await createActiveWorkflow( + { + name: 'Agent 2', + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + agentName: 'Second Agent', + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toHaveLength(2); + const agentNames = result.n8n.models.map((m) => m.name); + expect(agentNames).toContain('First Agent'); + expect(agentNames).toContain('Second Agent'); + }); + + it('should parse input modalities from chat trigger options', async () => { + await createActiveWorkflow( + { + name: 'Agent with specific mime types', + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + options: { + allowFileUploads: true, + allowedFilesMimeTypes: 'image/png, audio/mp3, application/pdf', + }, + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toHaveLength(1); + const inputModalities = result.n8n.models[0].metadata.inputModalities; + expect(inputModalities).toEqual(['text', 'image', 'audio', 'file']); + }); + + it('should parse all input modalities when wildcard mime type is used', async () => { + await createActiveWorkflow( + { + name: 'Agent with all file types', + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + options: { + allowFileUploads: true, + allowedFilesMimeTypes: '*/*', + }, + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toHaveLength(1); + const inputModalities = result.n8n.models[0].metadata.inputModalities; + expect(inputModalities).toEqual(['text', 'image', 'audio', 'video', 'file']); + }); + + it('should return only text modality when file uploads are disabled', async () => { + await createActiveWorkflow( + { + name: 'Agent without file uploads', + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + options: { + allowFileUploads: false, + }, + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toHaveLength(1); + expect(result.n8n.models[0].metadata.inputModalities).toEqual(['text']); + }); + + it('should include project icon in workflow model', async () => { + // Set project icon for the user's personal project + const personalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); + const projectIcon = { type: 'emoji' as const, value: '🤖' }; + await projectRepository.update(personalProject.id, { icon: projectIcon }); + + await createActiveWorkflow( + { + name: 'Agent with icon', + nodes: [ + { + id: uuid(), + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + availableInChat: true, + agentName: 'Icon Agent', + }, + }, + ], + connections: {}, + }, + member, + ); + + const result = await chatHubModelsService.getModels(member, emptyCredentialIds); + + expect(result.n8n.models).toHaveLength(1); + expect(result.n8n.models[0].icon).toEqual(projectIcon); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts index 5df244e1f34..e67d61d4d83 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts @@ -1,12 +1,15 @@ -import { mockInstance, testDb, testModules } from '@n8n/backend-test-utils'; +import { mockInstance, testDb, testModules, createActiveWorkflow } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; +import { ProjectRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { BinaryDataService } from 'n8n-core'; +import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; import { createAdmin, createMember } from '@test-integration/db/users'; import { ChatHubService } from '../chat-hub.service'; import { ChatHubMessageRepository } from '../chat-message.repository'; import { ChatHubSessionRepository } from '../chat-session.repository'; +import { ChatHubAgentRepository } from '../chat-hub-agent.repository'; mockInstance(BinaryDataService); @@ -16,7 +19,7 @@ beforeAll(async () => { }); beforeEach(async () => { - await testDb.truncate(['ChatHubMessage', 'ChatHubSession']); + await testDb.truncate(['ChatHubMessage', 'ChatHubSession', 'ChatHubAgent']); }); afterAll(async () => { @@ -27,6 +30,7 @@ describe('chatHub', () => { let chatHubService: ChatHubService; let messagesRepository: ChatHubMessageRepository; let sessionsRepository: ChatHubSessionRepository; + let agentRepository: ChatHubAgentRepository; let admin: User; let member: User; @@ -35,6 +39,7 @@ describe('chatHub', () => { chatHubService = Container.get(ChatHubService); messagesRepository = Container.get(ChatHubMessageRepository); sessionsRepository = Container.get(ChatHubSessionRepository); + agentRepository = Container.get(ChatHubAgentRepository); }); beforeEach(async () => { @@ -90,6 +95,83 @@ describe('chatHub', () => { expect(conversations.data[2].id).toBe(session3.id); }); + it('should return agentIcon for sessions with custom agents', async () => { + const agent = await agentRepository.createAgent({ + id: crypto.randomUUID(), + name: 'Test Agent', + description: 'Test agent description', + icon: { type: 'emoji', value: '🤖' }, + systemPrompt: 'You are a helpful assistant', + ownerId: member.id, + provider: 'openai', + model: 'gpt-4', + credentialId: null, + tools: [], + }); + + await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session with agent', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + provider: 'custom-agent', + agentId: agent.id, + tools: [], + }); + + const conversations = await chatHubService.getConversations(member.id, 20); + expect(conversations.data).toHaveLength(1); + expect(conversations.data[0].agentIcon).toEqual({ type: 'emoji', value: '🤖' }); + }); + + it('should return agentIcon for sessions with n8n workflow agents', async () => { + const projectRepository = Container.get(ProjectRepository); + + // Get member's personal project + const project = await projectRepository.getPersonalProjectForUserOrFail(member.id); + + // Update the project with an icon + await projectRepository.update(project.id, { + icon: { type: 'icon', value: 'workflow' }, + }); + + // Create an active workflow with chat trigger + const workflow = await createActiveWorkflow( + { + name: 'Chat Workflow', + nodes: [ + { + id: 'chat-trigger-1', + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1.4, + position: [0, 0], + parameters: { + availableInChat: true, + }, + }, + ], + connections: {}, + }, + member, + ); + + // Create a session with the workflow + await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session with workflow', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + provider: 'n8n', + workflowId: workflow.id, + tools: [], + }); + + const conversations = await chatHubService.getConversations(member.id, 20); + expect(conversations.data).toHaveLength(1); + expect(conversations.data[0].agentIcon).toEqual({ type: 'icon', value: 'workflow' }); + }); + describe('pagination', () => { it('should return hasMore=false and nextCursor=null when all sessions fit in one page', async () => { await sessionsRepository.createChatSession({ @@ -296,6 +378,83 @@ describe('chatHub', () => { expect(conversation.conversation.messages).toEqual({}); }); + it('should return agentIcon for conversation with custom agent', async () => { + const agent = await agentRepository.createAgent({ + id: crypto.randomUUID(), + name: 'Test Agent', + description: 'Test agent description', + icon: { type: 'emoji', value: '🤖' }, + systemPrompt: 'You are a helpful assistant', + ownerId: member.id, + provider: 'openai', + model: 'gpt-4', + credentialId: null, + tools: [], + }); + + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session with agent', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + provider: 'custom-agent', + agentId: agent.id, + tools: [], + }); + + const conversation = await chatHubService.getConversation(member.id, session.id); + expect(conversation).toBeDefined(); + expect(conversation.session.agentIcon).toEqual({ type: 'emoji', value: '🤖' }); + }); + + it('should return agentIcon for conversation with n8n workflow agent', async () => { + const projectRepository = Container.get(ProjectRepository); + + // Get member's personal project + const project = await projectRepository.getPersonalProjectForUserOrFail(member.id); + + // Update the project with an icon + await projectRepository.update(project.id, { + icon: { type: 'icon', value: 'workflow' }, + }); + + // Create an active workflow with chat trigger + const workflow = await createActiveWorkflow( + { + name: 'Chat Workflow', + nodes: [ + { + id: 'chat-trigger-1', + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1.4, + position: [0, 0], + parameters: { + availableInChat: true, + }, + }, + ], + connections: {}, + }, + member, + ); + + // Create a session with the workflow + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session with workflow', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + provider: 'n8n', + workflowId: workflow.id, + tools: [], + }); + + const conversation = await chatHubService.getConversation(member.id, session.id); + expect(conversation).toBeDefined(); + expect(conversation.session.agentIcon).toEqual({ type: 'icon', value: 'workflow' }); + }); + it('should get conversation with messages in expected order', async () => { const session = await sessionsRepository.createChatSession({ id: crypto.randomUUID(), diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts index b858118d24f..1435e935264 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts @@ -1,5 +1,5 @@ -import { ChatHubLLMProvider } from '@n8n/api-types'; -import { WithTimestamps, User, CredentialsEntity, JsonColumn } from '@n8n/db'; +import { ChatHubLLMProvider, AgentIconOrEmoji } from '@n8n/api-types'; +import { User, CredentialsEntity, JsonColumn, WithTimestamps } from '@n8n/db'; import { Column, Entity, ManyToOne, JoinColumn, PrimaryGeneratedColumn } from '@n8n/typeorm'; import { INode } from 'n8n-workflow'; @@ -20,6 +20,12 @@ export class ChatHubAgent extends WithTimestamps { @Column({ type: 'varchar', length: 512, nullable: true }) description: string | null; + /** + * The icon or emoji for the chat agent. + */ + @JsonColumn({ nullable: true }) + icon: AgentIconOrEmoji | null; + /** * The system prompt for the chat agent. */ diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts index 36a23bb4fcb..dd93653391a 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts @@ -1,8 +1,11 @@ -import { ChatModelsResponse } from '@n8n/api-types'; +import type { + ChatHubUpdateAgentRequest, + ChatHubCreateAgentRequest, + ChatModelDto, +} from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; -import { INode } from 'n8n-workflow'; import { v4 as uuidv4 } from 'uuid'; import type { ChatHubAgent } from './chat-hub-agent.entity'; @@ -20,21 +23,24 @@ export class ChatHubAgentService { private readonly chatHubCredentialsService: ChatHubCredentialsService, ) {} - async getAgentsByUserIdAsModels(userId: string): Promise { + async getAgentsByUserIdAsModels(userId: string): Promise { const agents = await this.getAgentsByUserId(userId); + return agents.map((agent) => this.convertAgentEntityToModel(agent)); + } + + convertAgentEntityToModel(agent: ChatHubAgent): ChatModelDto { return { - models: agents.map((agent) => ({ - name: agent.name, - description: agent.description ?? null, - model: { - provider: 'custom-agent', - agentId: agent.id, - }, - createdAt: agent.createdAt.toISOString(), - updatedAt: agent.updatedAt.toISOString(), - metadata: getModelMetadata(agent.provider, agent.model), - })), + name: agent.name, + description: agent.description ?? null, + icon: agent.icon, + model: { + provider: 'custom-agent', + agentId: agent.id, + }, + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + metadata: getModelMetadata(agent.provider, agent.model), }; } @@ -50,18 +56,7 @@ export class ChatHubAgentService { return agent; } - async createAgent( - user: User, - data: { - name: string; - description?: string; - systemPrompt: string; - credentialId: string; - provider: ChatHubAgent['provider']; - model: string; - tools: INode[]; - }, - ): Promise { + async createAgent(user: User, data: ChatHubCreateAgentRequest): Promise { // Ensure user has access to credentials if provided await this.chatHubCredentialsService.ensureCredentialById(user, data.credentialId); @@ -71,6 +66,7 @@ export class ChatHubAgentService { id, name: data.name, description: data.description ?? null, + icon: data.icon, systemPrompt: data.systemPrompt, ownerId: user.id, credentialId: data.credentialId, @@ -86,15 +82,7 @@ export class ChatHubAgentService { async updateAgent( id: string, user: User, - updates: { - name?: string; - description?: string; - systemPrompt?: string; - credentialId?: string; - provider?: string; - model?: string; - tools?: INode[]; - }, + updates: ChatHubUpdateAgentRequest, ): Promise { // First check if the agent exists and belongs to the user const existingAgent = await this.chatAgentRepository.getOneById(id, user.id); @@ -110,6 +98,7 @@ export class ChatHubAgentService { const updateData: Partial = {}; if (updates.name !== undefined) updateData.name = updates.name; if (updates.description !== undefined) updateData.description = updates.description ?? null; + if (updates.icon !== undefined) updateData.icon = updates.icon; if (updates.systemPrompt !== undefined) updateData.systemPrompt = updates.systemPrompt; if (updates.credentialId !== undefined) updateData.credentialId = updates.credentialId ?? null; if (updates.provider !== undefined) diff --git a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts index b433c937f72..1a58ac637a2 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts @@ -81,7 +81,7 @@ export class ChatHubMessage extends WithTimestamps { * ID of the custom agent that produced this message (if applicable). * Only set when provider is 'custom-agent'. */ - @Column({ type: 'varchar', length: 36, nullable: true }) + @Column({ type: 'uuid', nullable: true }) agentId: string | null; /** diff --git a/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts index 62dc013087f..fd61ec4b8c5 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts @@ -19,6 +19,7 @@ import { import type { INode } from 'n8n-workflow'; import type { ChatHubMessage } from './chat-hub-message.entity'; +import type { ChatHubAgent } from './chat-hub-agent.entity'; @Entity({ name: 'chat_hub_sessions' }) export class ChatHubSession extends WithTimestamps { @@ -94,9 +95,16 @@ export class ChatHubSession extends WithTimestamps { * ID of the custom agent to use (if applicable). * Only set when provider is 'custom-agent'. */ - @Column({ type: 'varchar', length: 36, nullable: true }) + @Column({ type: 'uuid', nullable: true }) agentId: string | null; + /** + * Custom n8n agent workflow to use (if applicable) + */ + @ManyToOne('ChatHubAgent', { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'agentId' }) + agent?: Relation | null; + /** * Cached display name of the agent/model. * Used for all providers (LLM providers, custom agents, and n8n workflows). diff --git a/packages/cli/src/modules/chat-hub/chat-hub.models.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.models.service.ts index 89e0337c3f3..8ea43ac6e22 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.models.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.models.service.ts @@ -7,7 +7,7 @@ import { type ChatModelDto, type ChatModelsResponse, } from '@n8n/api-types'; -import { In, WorkflowRepository, type User } from '@n8n/db'; +import { In, WorkflowRepository, type User, type WorkflowEntity } from '@n8n/db'; import { Service } from '@n8n/di'; import { CHAT_TRIGGER_NODE_TYPE, @@ -158,9 +158,9 @@ export class ChatHubModelsService { return { models: this.transformAndFilterModels(rawModels, 'mistralCloud') }; } case 'n8n': - return await this.fetchAgentWorkflowsAsModels(user); + return { models: await this.fetchAgentWorkflowsAsModels(user) }; case 'custom-agent': - return await this.chatHubAgentService.getAgentsByUserIdAsModels(user.id); + return { models: await this.chatHubAgentService.getAgentsByUserIdAsModels(user.id) }; } } @@ -712,7 +712,7 @@ export class ChatHubModelsService { ); } - private async fetchAgentWorkflowsAsModels(user: User): Promise { + private async fetchAgentWorkflowsAsModels(user: User): Promise { // Workflows are scanned by their latest version for chat trigger nodes. // This means that we might miss some active workflow versions that had chat triggers but // the latest version does not, but this trade-off is done for performance. @@ -728,59 +728,87 @@ export class ChatHubModelsService { // The workflow has to be active .filter((workflow) => !!workflow.activeVersionId); - const workflows = await this.workflowRepository.find({ - select: { id: true, name: true }, - where: { id: In(activeWorkflows.map((workflow) => workflow.id)) }, - relations: { activeVersion: true }, - }); - - const models: ChatModelDto[] = []; - - for (const { id, name, activeVersion } of workflows) { - if (!activeVersion) { - continue; - } - - const chatTrigger = activeVersion.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); - if (!chatTrigger) { - continue; - } - - const chatTriggerParams = chatTriggerParamsShape.safeParse(chatTrigger.parameters).data; - if (!chatTriggerParams?.availableInChat) { - continue; - } - - const inputModalities = this.chatHubWorkflowService.parseInputModalities( - chatTriggerParams.options, - ); - - const agentName = - chatTriggerParams.agentName && chatTriggerParams.agentName.trim().length > 0 - ? chatTriggerParams.agentName - : name; - - models.push({ - name: agentName, - description: chatTriggerParams.agentDescription ?? null, - model: { - provider: 'n8n', - workflowId: id, - }, - createdAt: activeVersion.createdAt ? activeVersion.createdAt.toISOString() : null, - updatedAt: activeVersion.updatedAt ? activeVersion.updatedAt.toISOString() : null, - metadata: { - inputModalities, - capabilities: { - functionCalling: false, - }, - available: true, - }, - }); + if (activeWorkflows.length === 0) { + return []; } + const workflows = await this.workflowRepository.find({ + select: { + id: true, + name: true, + shared: { + role: true, + project: { + id: true, + icon: { type: true, value: true }, + }, + }, + }, + where: { id: In(activeWorkflows.map((workflow) => workflow.id)) }, + relations: { + activeVersion: true, + shared: { + project: true, + }, + }, + }); + + return workflows.flatMap((workflow) => { + const model = this.extractModelFromWorkflow(workflow); + + return model ? [model] : []; + }); + } + + extractModelFromWorkflow({ + name, + activeVersion, + id, + shared, + }: WorkflowEntity): ChatModelDto | null { + if (!activeVersion) { + return null; + } + + const chatTrigger = activeVersion.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); + if (!chatTrigger) { + return null; + } + + const chatTriggerParams = chatTriggerParamsShape.safeParse(chatTrigger.parameters).data; + if (!chatTriggerParams?.availableInChat) { + return null; + } + + const inputModalities = this.chatHubWorkflowService.parseInputModalities( + chatTriggerParams.options, + ); + + const agentName = + chatTriggerParams.agentName && chatTriggerParams.agentName.trim().length > 0 + ? chatTriggerParams.agentName + : name; + + // Find the owner's project (home project) + const ownerSharedWorkflow = shared?.find((sw) => sw.role === 'workflow:owner'); + return { - models, + name: agentName, + description: chatTriggerParams.agentDescription ?? null, + icon: ownerSharedWorkflow?.project?.icon ?? null, + model: { + provider: 'n8n', + workflowId: id, + }, + createdAt: activeVersion.createdAt ? activeVersion.createdAt.toISOString() : null, + updatedAt: activeVersion.updatedAt ? activeVersion.updatedAt.toISOString() : null, + metadata: { + inputModalities, + capabilities: { + functionCalling: false, + }, + available: true, + }, }; } @@ -805,6 +833,7 @@ export class ChatHubModelsService { id, name: model.name, description: model.description ?? null, + icon: null, model: { provider, model: id, diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 444e2ac51a1..10bd9cb0b49 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -14,6 +14,7 @@ import { ChatHubN8nModel, ChatHubCustomAgentModel, type ChatHubUpdateConversationRequest, + type ChatHubSessionDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; @@ -74,6 +75,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExecutionService } from '@/executions/execution.service'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import { ChatHubModelsService } from './chat-hub.models.service'; @Service() export class ChatHubService { @@ -91,6 +93,7 @@ export class ChatHubService { private readonly chatHubAgentService: ChatHubAgentService, private readonly chatHubCredentialsService: ChatHubCredentialsService, private readonly chatHubWorkflowService: ChatHubWorkflowService, + private readonly chatHubModelsService: ChatHubModelsService, private readonly chatHubSettingsService: ChatHubSettingsService, private readonly chatHubAttachmentService: ChatHubAttachmentService, private readonly instanceSettings: InstanceSettings, @@ -1306,21 +1309,7 @@ export class ChatHubService { const nextCursor = hasMore ? data[data.length - 1].id : null; return { - data: data.map((session) => ({ - id: session.id, - title: session.title, - ownerId: session.ownerId, - lastMessageAt: session.lastMessageAt?.toISOString() ?? null, - credentialId: session.credentialId, - provider: session.provider, - model: session.model, - workflowId: session.workflowId, - agentId: session.agentId, - agentName: session.agentName ?? '', - createdAt: session.createdAt.toISOString(), - updatedAt: session.updatedAt.toISOString(), - tools: session.tools, - })), + data: data.map((session) => this.convertSessionEntityToDto(session)), nextCursor, hasMore, }; @@ -1338,21 +1327,7 @@ export class ChatHubService { const messages = await this.messageRepository.getManyBySessionId(sessionId); return { - session: { - id: session.id, - title: session.title, - ownerId: session.ownerId, - lastMessageAt: session.lastMessageAt?.toISOString() ?? null, - credentialId: session.credentialId, - provider: session.provider, - model: session.model, - workflowId: session.workflowId, - agentId: session.agentId, - agentName: session.agentName ?? '', - createdAt: session.createdAt.toISOString(), - updatedAt: session.updatedAt.toISOString(), - tools: session.tools, - }, + session: this.convertSessionEntityToDto(session), conversation: { messages: Object.fromEntries(messages.map((m) => [m.id, this.convertMessageToDto(m)])), }, @@ -1521,4 +1496,29 @@ export class ChatHubService { } } } + + private convertSessionEntityToDto(session: ChatHubSession): ChatHubSessionDto { + const agent = session.workflow + ? this.chatHubModelsService.extractModelFromWorkflow(session.workflow) + : session.agent + ? this.chatHubAgentService.convertAgentEntityToModel(session.agent) + : undefined; + + return { + id: session.id, + title: session.title, + ownerId: session.ownerId, + lastMessageAt: session.lastMessageAt?.toISOString() ?? null, + credentialId: session.credentialId, + provider: session.provider, + model: session.model, + workflowId: session.workflowId, + agentId: session.agentId, + agentName: agent?.name ?? session.agentName ?? session.model ?? '', + agentIcon: agent?.icon ?? null, + createdAt: session.createdAt.toISOString(), + updatedAt: session.updatedAt.toISOString(), + tools: session.tools, + }; + } } diff --git a/packages/cli/src/modules/chat-hub/chat-session.repository.ts b/packages/cli/src/modules/chat-hub/chat-session.repository.ts index 84cd1d3e083..8eb28891570 100644 --- a/packages/cli/src/modules/chat-hub/chat-session.repository.ts +++ b/packages/cli/src/modules/chat-hub/chat-session.repository.ts @@ -60,8 +60,14 @@ export class ChatHubSessionRepository extends Repository { async getManyByUserId(userId: string, limit: number, cursor?: string) { const queryBuilder = this.createQueryBuilder('session') + .leftJoinAndSelect('session.agent', 'agent') + .leftJoinAndSelect('session.workflow', 'workflow') + .leftJoinAndSelect('workflow.shared', 'shared') + .leftJoinAndSelect('shared.project', 'project') + .leftJoinAndSelect('workflow.activeVersion', 'activeVersion') .where('session.ownerId = :userId', { userId }) - .orderBy("COALESCE(session.lastMessageAt, '1970-01-01')", 'DESC') + .addSelect("COALESCE(session.lastMessageAt, '1970-01-01')", 'sortdate') + .orderBy('sortdate', 'DESC') .addOrderBy('session.id', 'ASC'); if (cursor) { @@ -94,7 +100,16 @@ export class ChatHubSessionRepository extends Repository { async (em) => { return await em.findOne(ChatHubSession, { where: { id, ownerId: userId }, - relations: ['messages'], + relations: { + messages: true, + agent: true, + workflow: { + shared: { + project: true, + }, + activeVersion: true, + }, + }, }); }, false, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 9ca08f5aa0e..d3feaab958c 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -150,6 +150,7 @@ import IconLucideMaximize from '~icons/lucide/maximize'; import IconLucideMaximize2 from '~icons/lucide/maximize-2'; import IconLucideMenu from '~icons/lucide/menu'; import IconLucideMessageCircle from '~icons/lucide/message-circle'; +import IconLucideMessageSquare from '~icons/lucide/message-square'; import IconLucideMessagesSquare from '~icons/lucide/messages-square'; import IconLucideMic from '~icons/lucide/mic'; import IconLucideMilestone from '~icons/lucide/milestone'; @@ -595,6 +596,7 @@ export const updatedIconSet = { 'maximize-2': IconLucideMaximize2, menu: IconLucideMenu, 'message-circle': IconLucideMessageCircle, + 'message-square': IconLucideMessageSquare, 'messages-square': IconLucideMessagesSquare, mic: IconLucideMic, milestone: IconLucideMilestone, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/constants.ts b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/constants.ts index c977e102078..8f187a60dca 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/constants.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/constants.ts @@ -41,6 +41,7 @@ export const ALL_ICON_PICKER_ICONS: IconName[] = [ 'git-branch', 'cog', 'message-circle', + 'message-square', 'messages-square', 'clipboard-list', 'clock', diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index 701c68e048f..86d366a1cc4 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -9,6 +9,7 @@ import ConditionalRouterLink from '../ConditionalRouterLink'; import N8nIcon from '../N8nIcon'; import type { IconName } from '../N8nIcon/icons'; import N8nText from '../N8nText'; +import N8nTooltip from '../N8nTooltip'; type BaseItem = { id: string; @@ -19,6 +20,7 @@ type BaseItem = { iconMargin?: boolean; route?: RouteLocationRaw; isDivider?: false; + description?: string; }; type Divider = { isDivider: true; id: string }; @@ -138,6 +140,7 @@ defineExpose({ data-test-id="navigation-submenu-item" :index="subitem.id" :disabled="subitem.disabled" + :class="{ [$style.menuItemWithTooltip]: subitem.description }" @click="emit('itemClick', $event)" > @@ -158,7 +161,15 @@ defineExpose({ - {{ subitem.title }} + {{ subitem.title }} + + + @@ -202,12 +213,6 @@ defineExpose({ } } } - - & hr { - border-top: none; - border-bottom: var(--border); - margin-block: var(--spacing--4xs); - } } .nestedSubmenu { @@ -246,9 +251,21 @@ defineExpose({ color: var(--color--text--tint-1); } + :global(.el-menu--horizontal .el-menu .el-menu-item) { + display: flex; + align-items: center; + gap: var(--spacing--2xs); + } + :global(.el-sub-menu__icon-arrow svg) { margin-top: auto; } + + & hr { + border-top: none; + border-bottom: var(--border); + margin-block: var(--spacing--4xs); + } } .subMenuTitle { @@ -261,4 +278,32 @@ defineExpose({ margin-right: var(--spacing--2xs); color: var(--color--text); } + +.menuItemWithTooltip { + position: relative; +} + +.menuItemTitle { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.infoTooltip { + flex-shrink: 0; + display: flex; + align-items: center; + padding-left: var(--spacing--xs); +} + +.infoIcon { + color: var(--color--text--tint-1); + cursor: pointer; + + &:hover { + color: var(--color--text); + } +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index cf86352a4a4..a18f8d760de 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -316,22 +316,23 @@ "chat.window.session.id.copy": "(click to copy)", "chat.window.session.reset": "Reset", "chat.window.session.resetSession": "Reset chat session", - "chatHub.agent.customAgents": "Custom Agents", + "chatHub.agent.personalAgents": "Personal agents", + "chatHub.agent.workflowAgents": "Workflow agents", "chatHub.agent.newAgent": "New Agent", + "chatHub.agent.unavailableAgent": "Unavailable agent", "chatHub.agent.configureCredentials": "Configure credentials", "chatHub.agent.addModel": "Add model", "chatHub.agent.credentialsMissing": "Credentials missing", "chatHub.agent.card.menu.edit": "Edit", "chatHub.agent.card.menu.delete": "Delete", "chatHub.agent.card.noDescription": "No description", - "chatHub.agent.card.badge.n8nWorkflow": "n8n workflow", - "chatHub.agent.card.badge.customAgent": "Custom agent", "chatHub.agent.card.button.edit": "Edit", "chatHub.agent.card.button.moreOptions": "More options", "chatHub.agent.editor.title.new": "New Agent", "chatHub.agent.editor.title.edit": "Edit Agent", - "chatHub.agent.editor.name.label": "Name", + "chatHub.agent.editor.name.label": "Icon and name", "chatHub.agent.editor.name.placeholder": "Enter agent name", + "chatHub.agent.editor.iconPicker.button.tooltip": "Change icon", "chatHub.agent.editor.description.label": "Description", "chatHub.agent.editor.description.placeholder": "Enter agent description (optional)", "chatHub.agent.editor.systemPrompt.label": "System Prompt", @@ -361,17 +362,19 @@ "chatHub.agents.delete.cancel.button": "Cancel", "chatHub.agents.delete.success": "Agent deleted successfully", "chatHub.agents.delete.error": "Could not delete the agent", - "chatHub.agents.title": "Custom Agents", - "chatHub.agents.description": "Use n8n workflow agents or create custom AI agents with specific instructions and behaviors", "chatHub.agents.button.newAgent": "New Agent", "chatHub.agents.search.placeholder": "Search", - "chatHub.agents.filter.all": "All", - "chatHub.agents.filter.customAgents": "Custom agents", - "chatHub.agents.filter.n8nWorkflows": "n8n workflows", "chatHub.agents.sort.updatedAt": "Sort by last updated", "chatHub.agents.sort.createdAt": "Sort by created", - "chatHub.agents.empty.noAgents": "No agents available. Create your first custom agent to get started.", "chatHub.agents.empty.noMatch": "No agents match your search criteria.", + "chatHub.workflowAgents.title": "Workflow Agents", + "chatHub.workflowAgents.description": "Browse and use AI agents built with n8n workflows", + "chatHub.workflowAgents.empty.noAgents": "No workflow agents available.", + "chatHub.workflowAgents.empty.noMatch": "No workflow agents match your search criteria.", + "chatHub.personalAgents.title": "Personal Agents", + "chatHub.personalAgents.description": "Create and manage custom AI agents with specific instructions and behaviors", + "chatHub.personalAgents.empty.noAgents": "No personal agents available. Create your first custom agent to get started.", + "chatHub.personalAgents.empty.noMatch": "No personal agents match your search criteria.", "chatHub.chat.greeting": "Hello, {name}!", "chatHub.chat.greeting.fallback": "User", "chatHub.chat.dropOverlay": "Drop files here to attach", @@ -450,8 +453,9 @@ "chatHub.session.delete.error": "Could not delete the conversation", "chatHub.sidebar.title": "Chat", "chatHub.sidebar.button.toggle": "Toggle sidebar", - "chatHub.sidebar.link.newChat": "New Chat", - "chatHub.sidebar.link.customAgents": "Custom Agents", + "chatHub.sidebar.link.newChat": "New chat", + "chatHub.sidebar.link.workflowAgents": "Workflow agents", + "chatHub.sidebar.link.personalAgents": "Personal agents", "chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.", "chatEmbed.infoTip.link": "More info", "chatEmbed.title": "Embed Chat in your website", diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatPersonalAgentsView.vue similarity index 60% rename from packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue rename to packages/frontend/editor-ui/src/features/ai/chatHub/ChatPersonalAgentsView.vue index 5239856ee7f..911901d52f0 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatPersonalAgentsView.vue @@ -2,11 +2,12 @@ import { useChatStore } from '@/features/ai/chatHub/chat.store'; import { useToast } from '@/app/composables/useToast'; import { useMessage } from '@/app/composables/useMessage'; -import { MODAL_CONFIRM, VIEWS } from '@/app/constants'; -import { N8nButton, N8nIcon, N8nInput, N8nOption, N8nSelect, N8nText } from '@n8n/design-system'; +import { MODAL_CONFIRM } from '@/app/constants'; +import { N8nButton, N8nText } from '@n8n/design-system'; import { computed, ref, watch } from 'vue'; import { useUIStore } from '@/app/stores/ui.store'; import ChatAgentCard from '@/features/ai/chatHub/components/ChatAgentCard.vue'; +import ChatAgentSearchSort from '@/features/ai/chatHub/components/ChatAgentSearchSort.vue'; import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials'; import { useUsersStore } from '@/features/settings/users/users.store'; import { type ChatHubConversationModel } from '@n8n/api-types'; @@ -14,9 +15,9 @@ import { filterAndSortAgents, stringifyModel } from '@/features/ai/chatHub/chat. import type { ChatAgentFilter } from '@/features/ai/chatHub/chat.types'; import { useMediaQuery } from '@vueuse/core'; import { AGENT_EDITOR_MODAL_KEY, MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants'; -import { useRouter } from 'vue-router'; import ChatLayout from '@/features/ai/chatHub/components/ChatLayout.vue'; import ChatSidebarOpener from '@/features/ai/chatHub/components/ChatSidebarOpener.vue'; +import SkeletonAgentCard from '@/features/ai/chatHub/components/SkeletonAgentCard.vue'; import { useI18n } from '@n8n/i18n'; const chatStore = useChatStore(); @@ -24,39 +25,17 @@ const uiStore = useUIStore(); const toast = useToast(); const message = useMessage(); const usersStore = useUsersStore(); -const router = useRouter(); const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY); const i18n = useI18n(); -const agentFilter = ref({ - search: '', - provider: '', - sortBy: 'updatedAt', -}); +const agentFilter = ref({ search: '', sortBy: 'updatedAt' }); const { credentialsByProvider } = useChatCredentials(usersStore.currentUserId ?? 'anonymous'); const readyToShowList = computed(() => chatStore.agentsReady); -const allModels = computed(() => - chatStore.agents.n8n.models.concat(chatStore.agents['custom-agent'].models), -); - +const allModels = computed(() => chatStore.agents['custom-agent'].models); const agents = computed(() => filterAndSortAgents(allModels.value, agentFilter.value)); -const providerOptions = computed( - () => - [ - { label: i18n.baseText('chatHub.agents.filter.all'), value: '' }, - { label: i18n.baseText('chatHub.agents.filter.customAgents'), value: 'custom-agent' }, - { label: i18n.baseText('chatHub.agents.filter.n8nWorkflows'), value: 'n8n' }, - ] as const, -); - -const sortOptions = computed(() => [ - { label: i18n.baseText('chatHub.agents.sort.updatedAt'), value: 'updatedAt' }, - { label: i18n.baseText('chatHub.agents.sort.createdAt'), value: 'createdAt' }, -]); - function handleCreateAgent() { uiStore.openModalWithData({ name: AGENT_EDITOR_MODAL_KEY, @@ -67,18 +46,6 @@ function handleCreateAgent() { } async function handleEditAgent(model: ChatHubConversationModel) { - if (model.provider === 'n8n') { - const routeData = router.resolve({ - name: VIEWS.WORKFLOW, - params: { - name: model.workflowId, - }, - }); - - window.open(routeData.href, '_blank'); - return; - } - if (model.provider === 'custom-agent') { uiStore.openModalWithData({ name: AGENT_EDITOR_MODAL_KEY, @@ -116,7 +83,7 @@ watch( credentialsByProvider, (credentials) => { if (credentials) { - void chatStore.fetchAgents(credentials); + void chatStore.fetchAgents(credentials, { minLoadingTime: 250 }); } }, { immediate: true }, @@ -128,9 +95,11 @@ watch(
- {{ i18n.baseText('chatHub.agents.title') }} + + {{ i18n.baseText('chatHub.personalAgents.title') }} + - {{ i18n.baseText('chatHub.agents.description') }} + {{ i18n.baseText('chatHub.personalAgents.description') }}
@@ -138,63 +107,31 @@ watch(
-
- - - + - - - - - - - -
- -