feat(core, editor): Better distinguish personal and workflow agents in chat (no-changelog) (#23032)

Co-authored-by: Jaakko Husso <jaakko@n8n.io>
This commit is contained in:
Suguru Inoue
2025-12-15 15:40:13 +01:00
committed by GitHub
co-authored by Jaakko Husso
parent 1b256ccc52
commit b7c92e07c7
40 changed files with 1574 additions and 393 deletions
+20
View File
@@ -29,6 +29,21 @@ export const chatHubLLMProviderSchema = z.enum([
]);
export type ChatHubLLMProvider = z.infer<typeof chatHubLLMProviderSchema>;
/**
* 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<typeof agentIconOrEmojiSchema>;
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(),
+2
View File
@@ -46,6 +46,8 @@ export {
type ChatHubAgentDto,
ChatHubCreateAgentRequest,
ChatHubUpdateAgentRequest,
type AgentIconOrEmoji,
agentIconOrEmojiSchema,
type EnrichedStructuredChunk,
type ChatHubAgentTool,
UpdateChatSettingsRequest,
@@ -83,6 +83,7 @@ type EntityName =
| 'DataTableColumn'
| 'ChatHubSession'
| 'ChatHubMessage'
| 'ChatHubAgent'
| 'OAuthClient'
| 'AuthorizationCode'
| 'AccessToken'
@@ -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']);
}
}
@@ -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,
];
@@ -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)`,
);
}
}
@@ -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,
];
@@ -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 };
+12 -2
View File
@@ -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('')
);
}
@@ -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);
});
});
});
});
@@ -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(),
@@ -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.
*/
@@ -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<ChatModelsResponse['custom-agent']> {
async getAgentsByUserIdAsModels(userId: string): Promise<ChatModelDto[]> {
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<ChatHubAgent> {
async createAgent(user: User, data: ChatHubCreateAgentRequest): Promise<ChatHubAgent> {
// 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<ChatHubAgent> {
// 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<ChatHubAgent> = {};
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)
@@ -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;
/**
@@ -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<ChatHubAgent> | null;
/**
* Cached display name of the agent/model.
* Used for all providers (LLM providers, custom agents, and n8n workflows).
@@ -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<ChatModelsResponse['n8n']> {
private async fetchAgentWorkflowsAsModels(user: User): Promise<ChatModelDto[]> {
// 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,
@@ -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,
};
}
}
@@ -60,8 +60,14 @@ export class ChatHubSessionRepository extends Repository<ChatHubSession> {
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<ChatHubSession> {
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,
@@ -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,
@@ -41,6 +41,7 @@ export const ALL_ICON_PICKER_ICONS: IconName[] = [
'git-branch',
'cog',
'message-circle',
'message-square',
'messages-square',
'clipboard-list',
'clock',
@@ -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)"
>
<slot name="item-icon" v-bind="{ item: subitem }">
@@ -158,7 +161,15 @@ defineExpose({
</template>
</slot>
{{ subitem.title }}
<span :class="$style.menuItemTitle">{{ subitem.title }}</span>
<N8nTooltip
v-if="subitem.description"
:content="subitem.description"
placement="right"
:class="$style.infoTooltip"
>
<N8nIcon icon="info" size="medium" :class="$style.infoIcon" />
</N8nTooltip>
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem>
</ConditionalRouterLink>
@@ -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);
}
}
</style>
+16 -12
View File
@@ -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",
@@ -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<ChatAgentFilter>({
search: '',
provider: '',
sortBy: 'updatedAt',
});
const agentFilter = ref<ChatAgentFilter>({ 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(
<div :class="[$style.container, { [$style.isMobileDevice]: isMobileDevice }]">
<div :class="$style.header">
<div :class="$style.headerContent">
<N8nText tag="h1" size="xlarge" bold>{{ i18n.baseText('chatHub.agents.title') }}</N8nText>
<N8nText tag="h1" size="xlarge" bold>
{{ i18n.baseText('chatHub.personalAgents.title') }}
</N8nText>
<N8nText color="text-light">
{{ i18n.baseText('chatHub.agents.description') }}
{{ i18n.baseText('chatHub.personalAgents.description') }}
</N8nText>
</div>
<N8nButton icon="plus" type="primary" size="medium" @click="handleCreateAgent">
@@ -138,63 +107,31 @@ watch(
</N8nButton>
</div>
<div v-if="readyToShowList && allModels.length > 0" :class="$style.controls">
<N8nInput
v-model="agentFilter.search"
:class="$style.search"
:placeholder="i18n.baseText('chatHub.agents.search.placeholder')"
clearable
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<ChatAgentSearchSort v-if="readyToShowList && allModels.length > 0" v-model="agentFilter" />
<N8nSelect v-model="agentFilter.provider" :class="$style.filter">
<N8nOption
v-for="option in providerOptions"
:key="String(option.value)"
:label="option.label"
:value="option.value"
/>
</N8nSelect>
<N8nSelect v-model="agentFilter.sortBy" :class="$style.sort">
<N8nOption
v-for="option in sortOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</N8nSelect>
</div>
<template v-if="!readyToShowList" />
<div v-else-if="allModels.length === 0" :class="$style.empty">
<N8nText color="text-light" size="medium">
{{ i18n.baseText('chatHub.agents.empty.noAgents') }}
</N8nText>
<div v-if="!readyToShowList" :class="$style.agentsGrid">
<SkeletonAgentCard v-for="i in 5" :key="i" />
</div>
<div v-else-if="agents.length === 0" :class="$style.empty">
<N8nText color="text-light" size="medium">
{{ i18n.baseText('chatHub.agents.empty.noMatch') }}
{{
allModels.length === 0
? i18n.baseText('chatHub.personalAgents.empty.noAgents')
: i18n.baseText('chatHub.personalAgents.empty.noMatch')
}}
</N8nText>
</div>
<div v-else :class="$style.agentsGrid">
<ChatAgentCard
v-for="agent in agents"
:key="stringifyModel(agent.model)"
:agent="agent"
@edit="handleEditAgent(agent.model)"
@delete="
agent.model.provider === 'custom-agent'
? handleDeleteAgent(agent.model.agentId)
: undefined
"
/>
<template v-for="agent in agents" :key="stringifyModel(agent.model)">
<ChatAgentCard
v-if="agent.model.provider === 'custom-agent'"
:agent="agent"
@edit="handleEditAgent(agent.model)"
@delete="handleDeleteAgent(agent.model.agentId)"
/>
</template>
</div>
</div>
<ChatSidebarOpener :class="$style.menuButton" />
@@ -240,25 +177,6 @@ watch(
gap: var(--spacing--3xs);
}
.controls {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
.search {
flex: 1;
min-width: 200px;
}
.filter {
width: 200px;
}
.sort {
width: 200px;
}
.empty {
display: flex;
align-items: center;
@@ -173,10 +173,10 @@ const selectedModel = computed<ChatModelDto | null>(() => {
return null;
}
return chatStore.getAgent(
model,
(currentConversation.value?.agentName || currentConversation.value?.model) ?? undefined,
);
return chatStore.getAgent(model, {
name: currentConversation.value?.agentName || currentConversation.value?.model,
icon: currentConversation.value?.agentIcon,
});
}
if (modelFromQuery.value) {
@@ -184,14 +184,17 @@ const selectedModel = computed<ChatModelDto | null>(() => {
}
if (chatStore.streaming?.sessionId === sessionId.value) {
return chatStore.getAgent(chatStore.streaming.model, chatStore.streaming.agentName);
return chatStore.streaming.agent;
}
if (!defaultModel.value) {
return null;
}
return chatStore.getAgent(defaultModel.value, defaultModel.value.cachedDisplayName);
return chatStore.getAgent(defaultModel.value, {
name: defaultModel.value.cachedDisplayName,
icon: defaultModel.value.cachedIcon,
});
});
const customAgentId = computed(() =>
@@ -384,6 +387,14 @@ watch(
defaultModel.value = { ...defaultModel.value, cachedDisplayName: agent.name };
}
if (
defaultModel.value &&
agent?.icon &&
(agent.icon.type !== prevAgent?.icon?.type || agent.icon.value !== prevAgent.icon.value)
) {
defaultModel.value = { ...defaultModel.value, cachedIcon: agent.icon };
}
if (
agent &&
!agent.metadata.capabilities.functionCalling &&
@@ -411,11 +422,10 @@ async function onSubmit(message: string, attachments: File[]) {
await chatStore.sendMessage(
sessionId.value,
message,
selectedModel.value.model,
selectedModel.value,
credentialsForSelectedProvider.value,
canSelectTools.value ? selectedTools.value : [],
attachments,
selectedModel.value.name,
);
inputRef.value?.setText('');
@@ -454,7 +464,7 @@ function handleEditMessage(message: ChatHubMessageDto) {
sessionId.value,
messageToEdit,
message.content,
selectedModel.value.model,
selectedModel.value,
credentialsForSelectedProvider.value,
);
editingMessageId.value = undefined;
@@ -475,22 +485,29 @@ function handleRegenerateMessage(message: ChatHubMessageDto) {
chatStore.regenerateMessage(
sessionId.value,
messageToRetry,
selectedModel.value.model,
selectedModel.value,
credentialsForSelectedProvider.value,
);
}
async function handleSelectModel(selection: ChatHubConversationModel, displayName?: string) {
const agentName = displayName ?? chatStore.getAgent(selection)?.name ?? '';
async function handleSelectModel(
selection: ChatHubConversationModel,
selectedAgent?: ChatModelDto,
) {
const agent = selectedAgent ?? chatStore.getAgent(selection);
if (currentConversation.value) {
try {
await chatStore.updateSessionModel(sessionId.value, selection, agentName);
await chatStore.updateSessionModel(sessionId.value, selection, agent.name);
} catch (error) {
toast.showError(error, i18n.baseText('chatHub.error.updateModelFailed'));
}
} else {
defaultModel.value = { ...selection, cachedDisplayName: agentName };
defaultModel.value = {
...selection,
cachedDisplayName: agent.name,
cachedIcon: agent.icon ?? undefined,
};
// Remove query params (if exists) and focus input
await router.push({ name: CHAT_VIEW, force: true }); // remove query params
@@ -498,7 +515,7 @@ async function handleSelectModel(selection: ChatHubConversationModel, displayNam
}
async function handleSelectAgent(selection: ChatModelDto) {
await handleSelectModel(selection.model, selection.name);
await handleSelectModel(selection.model, selection);
}
function handleSwitchAlternative(messageId: string) {
@@ -615,6 +632,7 @@ function onFilesDropped(files: File[]) {
:is-editing="editingMessageId === message.id"
:is-streaming="message.status === 'running'"
:cached-agent-display-name="selectedModel?.name ?? null"
:cached-agent-icon="selectedModel?.icon ?? null"
:min-height="
didSubmitInCurrentSession &&
message.type === 'ai' &&
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { VIEWS } from '@/app/constants';
import { N8nText } from '@n8n/design-system';
import { computed, ref, watch } from 'vue';
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';
import { filterAndSortAgents, stringifyModel } from '@/features/ai/chatHub/chat.utils';
import type { ChatAgentFilter } from '@/features/ai/chatHub/chat.types';
import { useMediaQuery } from '@vueuse/core';
import { 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();
const usersStore = useUsersStore();
const router = useRouter();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const i18n = useI18n();
const agentFilter = ref<ChatAgentFilter>({ search: '', sortBy: 'updatedAt' });
const { credentialsByProvider } = useChatCredentials(usersStore.currentUserId ?? 'anonymous');
const readyToShowList = computed(() => chatStore.agentsReady);
const allModels = computed(() => chatStore.agents.n8n.models);
const agents = computed(() => filterAndSortAgents(allModels.value, agentFilter.value));
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;
}
}
watch(
credentialsByProvider,
(credentials) => {
if (credentials) {
void chatStore.fetchAgents(credentials, { minLoadingTime: 250 });
}
},
{ immediate: true },
);
</script>
<template>
<ChatLayout>
<div :class="[$style.container, { [$style.isMobileDevice]: isMobileDevice }]">
<div :class="$style.header">
<div :class="$style.headerContent">
<N8nText tag="h1" size="xlarge" bold>
{{ i18n.baseText('chatHub.workflowAgents.title') }}
</N8nText>
<N8nText color="text-light">
{{ i18n.baseText('chatHub.workflowAgents.description') }}
</N8nText>
</div>
</div>
<ChatAgentSearchSort v-if="readyToShowList && allModels.length > 0" v-model="agentFilter" />
<div v-if="!readyToShowList" :class="$style.agentsGrid">
<SkeletonAgentCard v-for="i in 5" :key="i" />
</div>
<div v-else-if="agents.length === 0" :class="$style.empty">
<N8nText color="text-light" size="medium">
{{
allModels.length === 0
? i18n.baseText('chatHub.workflowAgents.empty.noAgents')
: i18n.baseText('chatHub.workflowAgents.empty.noMatch')
}}
</N8nText>
</div>
<div v-else :class="$style.agentsGrid">
<ChatAgentCard
v-for="agent in agents"
:key="stringifyModel(agent.model)"
:agent="agent"
@edit="handleEditAgent(agent.model)"
/>
</div>
</div>
<ChatSidebarOpener :class="$style.menuButton" />
</ChatLayout>
</template>
<style lang="scss" module>
.container {
align-self: center;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: var(--content-container--width);
padding: var(--spacing--xl);
gap: var(--spacing--xl);
overflow-y: auto;
position: relative;
}
.menuButton {
position: absolute;
top: 0;
left: 0;
margin: var(--spacing--sm);
.isMobileDevice & {
margin: var(--spacing--2xs);
}
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing--lg);
width: 100%;
}
.headerContent {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
flex: 1;
width: 100%;
}
.agentsGrid {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
</style>
@@ -41,12 +41,14 @@ import {
type ChatModelDto,
type ChatHubLLMProvider,
type ChatProviderSettingsDto,
type AgentIconOrEmoji,
} from '@n8n/api-types';
import type {
CredentialsMap,
ChatMessage,
ChatConversation,
ChatStreamingState,
FetchOptions,
} from './chat.types';
import { retry } from '@n8n/utils/retry';
import {
@@ -289,14 +291,17 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
message.updatedAt = new Date().toISOString();
}
async function fetchAgents(credentialMap: CredentialsMap) {
agents.value = await fetchChatModelsApi(rootStore.restApiContext, {
credentials: credentialMap,
});
async function fetchAgents(credentialMap: CredentialsMap, options: FetchOptions = {}) {
[agents.value] = await Promise.all([
fetchChatModelsApi(rootStore.restApiContext, {
credentials: credentialMap,
}),
new Promise((r) => setTimeout(r, options.minLoadingTime ?? 0)),
]);
return agents.value;
}
async function fetchSessions(reset: boolean) {
async function fetchSessions(reset: boolean, options: FetchOptions = {}) {
if (sessionsLoadingMore.value) {
return;
}
@@ -318,7 +323,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const cursor = reset ? undefined : (sessions.value?.nextCursor ?? undefined);
const [response] = await Promise.all([
fetchSessionsApi(rootStore.restApiContext, 40, cursor),
new Promise((resolve) => setTimeout(resolve, 500)),
new Promise((resolve) => setTimeout(resolve, options.minLoadingTime ?? 0)),
]);
if (reset || sessions.value.ids === null) {
@@ -337,9 +342,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
}
async function fetchMoreSessions() {
async function fetchMoreSessions(options: FetchOptions = {}) {
if (sessions.value?.hasMore && !sessionsLoadingMore.value) {
await fetchSessions(false);
await fetchSessions(false, options);
}
}
@@ -507,11 +512,10 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
async function sendMessage(
sessionId: ChatSessionId,
message: string,
model: ChatHubConversationModel,
agent: ChatModelDto,
credentials: ChatHubSendMessageRequest['credentials'],
tools: INode[],
files: File[] = [],
agentName: string,
) {
const messageId = uuidv4();
const conversation = ensureConversation(sessionId);
@@ -533,7 +537,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
name: 'User',
content: message,
provider: null,
model: isLlmProviderModel(model) ? model.model : null,
model: isLlmProviderModel(agent.model) ? agent.model.model : null,
workflowId: null,
executionId: null,
agentId: null,
@@ -551,10 +555,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
streaming.value = {
promptId: messageId,
sessionId,
model,
retryOfMessageId: null,
tools,
agentName,
agent,
};
if (!sessions.value.byId[sessionId]) {
@@ -566,7 +569,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
sendMessageApi(
rootStore.restApiContext,
{
model,
model: agent.model,
messageId,
sessionId,
message,
@@ -574,7 +577,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
previousMessageId,
tools,
attachments,
agentName,
agentName: agent.name,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
onStreamMessage,
@@ -583,8 +586,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
);
telemetry.track('User sent chat hub message', {
...flattenModel(model),
is_custom: model.provider === 'custom-agent',
...flattenModel(agent.model),
is_custom: agent.model.provider === 'custom-agent',
chat_session_id: sessionId,
});
}
@@ -593,7 +596,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
sessionId: ChatSessionId,
editId: ChatMessageId,
content: string,
model: ChatHubConversationModel,
agent: ChatModelDto,
credentials: ChatHubSendMessageRequest['credentials'],
) {
const promptId = uuidv4();
@@ -631,10 +634,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
streaming.value = {
promptId,
sessionId,
model,
agent,
retryOfMessageId: null,
tools: [],
agentName: sessions.value.byId[sessionId]?.agentName ?? '',
};
editMessageApi(
@@ -642,7 +644,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
sessionId,
editId,
{
model,
model: agent.model,
messageId: promptId,
message: content,
credentials,
@@ -654,8 +656,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
);
telemetry.track('User edited chat hub message', {
...flattenModel(model),
is_custom: model.provider === 'custom-agent',
...flattenModel(agent.model),
is_custom: agent.model.provider === 'custom-agent',
chat_session_id: sessionId,
chat_message_id: editId,
});
@@ -664,7 +666,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
function regenerateMessage(
sessionId: ChatSessionId,
retryId: ChatMessageId,
model: ChatHubConversationModel,
agent: ChatModelDto,
credentials: ChatHubSendMessageRequest['credentials'],
) {
const conversation = ensureConversation(sessionId);
@@ -677,10 +679,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
streaming.value = {
promptId: retryId,
sessionId,
model,
agent,
retryOfMessageId: retryId,
tools: [],
agentName: sessions.value.byId[sessionId]?.agentName ?? '',
};
regenerateMessageApi(
@@ -688,7 +689,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
sessionId,
retryId,
{
model,
model: agent.model,
credentials,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
@@ -698,8 +699,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
);
telemetry.track('User regenerated chat hub message', {
...flattenModel(model),
is_custom: model.provider === 'custom-agent',
...flattenModel(agent.model),
is_custom: agent.model.provider === 'custom-agent',
chat_session_id: sessionId,
chat_message_id: retryId,
});
@@ -750,10 +751,10 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
model: ChatHubConversationModel,
agentName: string,
) {
await updateConversationApi(rootStore.restApiContext, sessionId, {
const result = await updateConversationApi(rootStore.restApiContext, sessionId, {
agent: { model, name: agentName },
});
updateSession(sessionId, { ...model, agentName });
updateSession(sessionId, result.session);
}
async function deleteSession(sessionId: ChatSessionId) {
@@ -802,6 +803,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
},
name: customAgent.name,
description: customAgent.description ?? null,
icon: customAgent.icon,
createdAt: customAgent.createdAt,
updatedAt: customAgent.updatedAt,
metadata: baseModel?.metadata ?? {
@@ -858,7 +860,10 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
await fetchAgents(credentials);
}
function getAgent(model: ChatHubConversationModel, fallbackName: string = ''): ChatModelDto {
function getAgent(
model: ChatHubConversationModel,
fallback?: Partial<{ name: string | null; icon: AgentIconOrEmoji | null }>,
): ChatModelDto {
const agent = agents.value?.[model.provider]?.models.find((candidate) =>
isMatchedAgent(candidate, model),
);
@@ -869,8 +874,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
return {
model,
name: fallbackName,
name: fallback?.name ?? '',
description: null,
icon: fallback?.icon ?? null,
createdAt: null,
updatedAt: null,
// Assume file attachment and tools are supported
@@ -5,10 +5,11 @@ import {
type ChatHubSessionDto,
type ChatHubConversationDto,
type ChatSessionId,
type ChatHubConversationModel,
type EnrichedStructuredChunk,
type ChatHubProvider,
chatHubConversationModelSchema,
type ChatModelDto,
agentIconOrEmojiSchema,
} from '@n8n/api-types';
import type { INode } from 'n8n-workflow';
import { z } from 'zod';
@@ -72,17 +73,15 @@ export interface GroupedConversations {
export interface ChatAgentFilter {
sortBy: 'updatedAt' | 'createdAt';
provider: 'custom-agent' | 'n8n' | '';
search: string;
}
export interface ChatStreamingState extends Partial<EnrichedStructuredChunk['metadata']> {
promptId: ChatMessageId;
sessionId: ChatSessionId;
model: ChatHubConversationModel;
retryOfMessageId: ChatMessageId | null;
tools: INode[];
agentName: string;
agent: ChatModelDto;
}
export interface FlattenedModel {
@@ -93,7 +92,12 @@ export interface FlattenedModel {
}
export const chatHubConversationModelWithCachedDisplayNameSchema = chatHubConversationModelSchema
.and(z.object({ cachedDisplayName: z.string().optional() }))
.and(
z.object({
cachedDisplayName: z.string().optional(),
cachedIcon: agentIconOrEmojiSchema.optional(),
}),
)
.transform((value) => ({
...value,
cachedDisplayName: value.cachedDisplayName || (isLlmProviderModel(value) ? value.model : ''),
@@ -102,3 +106,7 @@ export const chatHubConversationModelWithCachedDisplayNameSchema = chatHubConver
export type ChatHubConversationModelWithCachedDisplayName = z.infer<
typeof chatHubConversationModelWithCachedDisplayNameSchema
>;
export interface FetchOptions {
minLoadingTime?: number;
}
@@ -9,6 +9,7 @@ import {
type ChatHubProvider,
type ChatHubLLMProvider,
type ChatHubInputModality,
type AgentIconOrEmoji,
} from '@n8n/api-types';
import type {
ChatMessage,
@@ -20,6 +21,7 @@ import type {
} from './chat.types';
import { CHAT_VIEW } from './constants';
import { v4 as uuidv4 } from 'uuid';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
export function findOneFromModelsResponse(response: ChatModelsResponse): ChatModelDto | undefined {
for (const provider of chatHubProviderSchema.options) {
@@ -176,11 +178,6 @@ export function filterAndSortAgents(
filtered = filtered.filter((model) => model.name.toLowerCase().includes(query));
}
// Apply provider filter
if (filter.provider !== '') {
filtered = filtered.filter((model) => model.model.provider === filter.provider);
}
// Apply sorting
filtered = [...filtered].sort((a, b) => {
const dateAStr = a[filter.sortBy];
@@ -259,8 +256,8 @@ export function createAiMessageFromStreamingState(
responses: [],
alternatives: [],
attachments: [],
...(streaming?.model
? flattenModel(streaming.model)
...(streaming?.agent
? flattenModel(streaming.agent.model)
: {
provider: null,
model: null,
@@ -345,11 +342,12 @@ export function createSessionFromStreamingState(streaming: ChatStreamingState):
ownerId: '',
lastMessageAt: new Date().toISOString(),
credentialId: null,
agentName: streaming.agentName,
agentName: streaming.agent.name,
agentIcon: streaming.agent.icon,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tools: streaming.tools,
...flattenModel(streaming.model),
...flattenModel(streaming.agent.model),
};
}
@@ -375,3 +373,13 @@ export function createMimeTypes(modalities: ChatHubInputModality[]): string {
return mimeTypes.join(',');
}
export const personalAgentDefaultIcon: AgentIconOrEmoji = {
type: 'icon',
value: 'message-square' satisfies IconName,
};
export const workflowAgentDefaultIcon: AgentIconOrEmoji = {
type: 'icon',
value: 'bot' satisfies IconName,
};
@@ -10,11 +10,20 @@ import {
emptyChatModelsResponse,
type ChatModelsResponse,
type ChatHubBaseLLMModel,
type AgentIconOrEmoji,
type ChatHubConversationModel,
type ChatHubProvider,
type ChatModelDto,
} from '@n8n/api-types';
import { N8nButton, N8nHeading, N8nInput, N8nInputLabel, N8nSpinner } from '@n8n/design-system';
import {
N8nButton,
N8nHeading,
N8nIconPicker,
N8nInput,
N8nInputLabel,
N8nSpinner,
} from '@n8n/design-system';
import type { IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import { useI18n } from '@n8n/i18n';
import { assert } from '@n8n/utils/assert';
import { createEventBus } from '@n8n/utils/event-bus';
@@ -22,7 +31,7 @@ import { computed, ref, useTemplateRef, watch } from 'vue';
import type { CredentialsMap } from '../chat.types';
import type { INode } from 'n8n-workflow';
import ToolsSelector from './ToolsSelector.vue';
import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils';
import { personalAgentDefaultIcon, isLlmProviderModel } from '@/features/ai/chatHub/chat.utils';
import { useCustomAgent } from '@/features/ai/chatHub/composables/useCustomAgent';
import { useUIStore } from '@/app/stores/ui.store';
import { TOOLS_SELECTOR_MODAL_KEY } from '@/features/ai/chatHub/constants';
@@ -57,13 +66,16 @@ const tools = ref<INode[]>([]);
const agents = ref<ChatModelsResponse>(emptyChatModelsResponse);
const isLoadingAgents = ref(false);
const nameInputRef = useTemplateRef('nameInput');
const icon = ref<AgentIconOrEmoji>(personalAgentDefaultIcon);
const agentSelectedCredentials = ref<CredentialsMap>({});
const credentialIdForSelectedModelProvider = computed(
() => selectedModel.value && agentMergedCredentials.value[selectedModel.value.provider],
);
const selectedAgent = computed(
() => selectedModel.value && chatStore.getAgent(selectedModel.value, selectedModel.value.model),
() =>
selectedModel.value &&
chatStore.getAgent(selectedModel.value, { name: selectedModel.value.model }),
);
const isEditMode = computed(() => !!props.data.agentId);
@@ -119,6 +131,7 @@ watch(
(agent) => {
if (!agent) return;
icon.value = agent.icon ?? personalAgentDefaultIcon;
name.value = agent.name;
description.value = agent.description ?? '';
systemPrompt.value = agent.systemPrompt;
@@ -187,6 +200,7 @@ async function onSave() {
...selectedModel.value,
credentialId: credentialIdForSelectedModelProvider.value,
tools: tools.value,
icon: icon.value,
};
if (isEditMode.value && props.data.agentId) {
@@ -288,15 +302,21 @@ function onSelectTools() {
:label="i18n.baseText('chatHub.agent.editor.name.label')"
:required="true"
>
<N8nInput
ref="nameInput"
id="agent-name"
v-model="name"
:placeholder="i18n.baseText('chatHub.agent.editor.name.placeholder')"
:maxlength="128"
:class="$style.input"
:disabled="isLoadingAgent"
/>
<div :class="$style.agentName">
<N8nIconPicker
v-model="icon as IconOrEmoji"
:button-tooltip="i18n.baseText('chatHub.agent.editor.iconPicker.button.tooltip')"
/>
<N8nInput
ref="nameInput"
id="agent-name"
v-model="name"
:placeholder="i18n.baseText('chatHub.agent.editor.name.placeholder')"
:maxlength="128"
:class="$style.agentNameInput"
:disabled="isLoadingAgent"
/>
</div>
</N8nInputLabel>
<N8nInputLabel
@@ -414,6 +434,16 @@ function onSelectTools() {
width: 100%;
}
.agentName {
display: flex;
align-items: center;
gap: var(--spacing--xs);
}
.agentNameInput {
flex: 1;
}
.row {
display: flex;
flex-direction: row;
@@ -2,8 +2,15 @@
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { type ChatModelDto, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
import { N8nAvatar, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import { computed } from 'vue';
import {
isLlmProviderModel,
personalAgentDefaultIcon,
workflowAgentDefaultIcon,
} from '../chat.utils';
import { useI18n } from '@n8n/i18n';
defineProps<{
agent: ChatModelDto | null;
@@ -13,22 +20,28 @@ defineProps<{
const credentialsStore = useCredentialsStore();
const isCredentialsIconReady = computed(() => credentialsStore.allCredentialTypes.length > 0);
const i18n = useI18n();
</script>
<template>
<N8nTooltip :show-after="100" placement="left" :disabled="!tooltip">
<template v-if="agent" #content>{{ agent.name }}</template>
<template #content>{{
agent?.name || i18n.baseText('chatHub.agent.unavailableAgent')
}}</template>
<span v-if="agent?.icon?.type === 'emoji'" :class="[$style.emoji, $style[size]]">
{{ agent.icon.value }}
</span>
<N8nIcon
v-if="!agent"
icon="messages-square"
v-else-if="!agent || !isLlmProviderModel(agent.model)"
:color="size === 'sm' ? 'text-base' : 'text-light'"
:class="[$style.n8nIcon, $style[size]]"
:icon="
(agent?.icon?.value ??
(agent?.model.provider === 'n8n' ? workflowAgentDefaultIcon : personalAgentDefaultIcon)
.value) as IconName
"
:size="size === 'lg' ? 'xxlarge' : size === 'sm' ? 'large' : 'xlarge'"
/>
<N8nAvatar
v-else-if="agent.model.provider === 'custom-agent' || agent.model.provider === 'n8n'"
:class="[$style.avatar, $style[size]]"
:first-name="agent.name"
:size="size === 'lg' ? 'medium' : size === 'sm' ? 'xxsmall' : 'xsmall'"
/>
<CredentialIcon
v-else
:class="[$style.credentialsIcon, { [$style.isReady]: isCredentialsIconReady }]"
@@ -39,8 +52,41 @@ const isCredentialsIconReady = computed(() => credentialsStore.allCredentialType
</template>
<style lang="scss" module>
.avatar.md {
transform: scale(1.2);
.n8nIcon {
outline: none;
&.lg {
width: 24px;
height: 24px;
& g,
& path {
stroke-width: 1.25;
}
}
}
.emoji {
display: inline-flex;
align-items: center;
justify-content: center;
&.sm {
width: 16px;
height: 16px;
}
&.md {
width: 20px;
height: 20px;
font-size: 20px;
}
&.lg {
width: 24px;
height: 24px;
font-size: 24px;
}
}
.credentialsIcon {
@@ -3,10 +3,11 @@ import { computed } from 'vue';
import { getAgentRoute } from '@/features/ai/chatHub/chat.utils';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import type { ChatModelDto } from '@n8n/api-types';
import { N8nActionDropdown, N8nBadge, N8nIconButton, N8nText } from '@n8n/design-system';
import { N8nActionDropdown, N8nIconButton, N8nText } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { RouterLink } from 'vue-router';
import { hasPermission } from '@/app/utils/rbac/permissions';
const { agent } = defineProps<{
agent: ChatModelDto;
@@ -22,14 +23,17 @@ const i18n = useI18n();
type MenuAction = 'edit' | 'delete';
const menuItems = computed<Array<ActionDropdownItem<MenuAction>>>(() => {
return [
{ id: 'edit' as const, label: i18n.baseText('chatHub.agent.card.menu.edit') },
...(agent.model.provider === 'custom-agent'
? [{ id: 'delete' as const, label: i18n.baseText('chatHub.agent.card.menu.delete') }]
: []),
];
return agent.model.provider === 'custom-agent'
? [{ id: 'delete' as const, label: i18n.baseText('chatHub.agent.card.menu.delete') }]
: [];
});
const canEdit = computed(
() =>
agent.model.provider === 'custom-agent' ||
hasPermission(['rbac'], { rbac: { scope: ['workflow:read'] } }),
);
function handleSelectMenu(action: MenuAction) {
switch (action) {
case 'delete':
@@ -54,16 +58,9 @@ function handleSelectMenu(action: MenuAction) {
</N8nText>
</div>
<N8nBadge theme="tertiary" show-border :class="$style.badge">
{{
agent.model.provider === 'n8n'
? i18n.baseText('chatHub.agent.card.badge.n8nWorkflow')
: i18n.baseText('chatHub.agent.card.badge.customAgent')
}}
</N8nBadge>
<div :class="$style.actions">
<N8nIconButton
v-if="canEdit"
icon="pen"
type="tertiary"
size="medium"
@@ -71,6 +68,7 @@ function handleSelectMenu(action: MenuAction) {
@click.prevent="emit('edit')"
/>
<N8nActionDropdown
v-if="menuItems.length > 0"
:items="menuItems"
placement="bottom-end"
@select="handleSelectMenu"
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { N8nIcon, N8nInput2, N8nSelect2 } from '@n8n/design-system';
import { computed, ref, watch } from 'vue';
import { useI18n } from '@n8n/i18n';
import { refDebounced } from '@vueuse/core';
import type { ChatAgentFilter } from '@/features/ai/chatHub/chat.types';
const props = defineProps<{
modelValue: ChatAgentFilter;
}>();
const emit = defineEmits<{
'update:modelValue': [value: ChatAgentFilter];
}>();
const i18n = useI18n();
const sortOptions = computed(() => [
{ label: i18n.baseText('chatHub.agents.sort.updatedAt'), value: 'updatedAt' as const },
{ label: i18n.baseText('chatHub.agents.sort.createdAt'), value: 'createdAt' as const },
]);
const localSearch = ref(props.modelValue.search);
const debouncedSearch = refDebounced(localSearch, 300);
// Sync local search with incoming modelValue changes
watch(
() => props.modelValue.search,
(newSearch) => {
if (newSearch !== localSearch.value) {
localSearch.value = newSearch;
}
},
);
// Emit debounced search changes
watch(debouncedSearch, (newSearch) => {
if (newSearch !== props.modelValue.search) {
emit('update:modelValue', { ...props.modelValue, search: newSearch });
}
});
function updateSortBy(value: 'updatedAt' | 'createdAt') {
emit('update:modelValue', { ...props.modelValue, sortBy: value });
}
</script>
<template>
<div :class="$style.controls">
<N8nInput2
v-model="localSearch"
:class="$style.search"
size="medium"
:placeholder="i18n.baseText('chatHub.agents.search.placeholder')"
clearable
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput2>
<N8nSelect2
size="medium"
:model-value="modelValue.sortBy"
:class="$style.sort"
:items="sortOptions"
@update:model-value="updateSortBy"
/>
</div>
</template>
<style lang="scss" module>
.controls {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
.search {
flex: 1;
min-width: 200px;
}
.sort {
width: 200px;
}
</style>
@@ -2,7 +2,7 @@
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue';
import { useChatHubMarkdownOptions } from '@/features/ai/chatHub/composables/useChatHubMarkdownOptions';
import type { ChatMessageId, ChatModelDto } from '@n8n/api-types';
import type { AgentIconOrEmoji, ChatMessageId, ChatModelDto } from '@n8n/api-types';
import { N8nButton, N8nIcon, N8nInput } from '@n8n/design-system';
import { useSpeechSynthesis } from '@vueuse/core';
import { computed, onBeforeMount, ref, useCssModule, useTemplateRef, watch } from 'vue';
@@ -25,6 +25,7 @@ const {
isStreaming,
minHeight,
cachedAgentDisplayName,
cachedAgentIcon,
containerWidth,
} = defineProps<{
message: ChatMessage;
@@ -32,6 +33,7 @@ const {
isEditing: boolean;
isStreaming: boolean;
cachedAgentDisplayName: string | null;
cachedAgentIcon: AgentIconOrEmoji | null;
/**
* minHeight allows scrolling agent's response to the top while it is being generated
*/
@@ -72,7 +74,7 @@ const agent = computed<ChatModelDto | null>(() => {
return null;
}
return chatStore.getAgent(model, cachedAgentDisplayName ?? undefined);
return chatStore.getAgent(model, { name: cachedAgentDisplayName, icon: cachedAgentIcon });
});
const attachments = computed(() =>
@@ -202,8 +204,7 @@ onBeforeMount(() => {
>
<div :class="$style.avatar">
<N8nIcon v-if="message.type === 'human'" icon="user" width="20" height="20" />
<ChatAgentAvatar v-else-if="agent" :agent="agent" size="md" tooltip />
<N8nIcon v-else icon="sparkles" width="20" height="20" />
<ChatAgentAvatar v-else :agent="agent" size="md" tooltip />
</div>
<div :class="$style.content">
<div v-if="isEditing" :class="$style.editContainer">
@@ -37,7 +37,7 @@ const agent = computed<ChatModelDto | null>(() => {
return null;
}
return chatStore.getAgent(model, session.agentName);
return chatStore.getAgent(model, { name: session.agentName, icon: session.agentIcon });
});
const dropdownItems = computed<Array<ActionDropdownItem<SessionAction>>>(() => [
@@ -7,7 +7,11 @@ import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { groupConversationsByDate } from '@/features/ai/chatHub/chat.utils';
import ChatSidebarLink from '@/features/ai/chatHub/components/ChatSidebarLink.vue';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import { CHAT_VIEW, CHAT_AGENTS_VIEW } from '@/features/ai/chatHub/constants';
import {
CHAT_VIEW,
CHAT_WORKFLOW_AGENTS_VIEW,
CHAT_PERSONAL_AGENTS_VIEW,
} from '@/features/ai/chatHub/constants';
import { useSettingsStore } from '@/app/stores/settings.store';
import { N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system';
import Logo from '@n8n/design-system/components/N8nLogo';
@@ -111,7 +115,7 @@ useIntersectionObserver(
loadMoreTrigger,
([{ isIntersecting }]) => {
if (isIntersecting) {
void chatStore.fetchMoreSessions();
void chatStore.fetchMoreSessions({ minLoadingTime: 250 });
}
},
{ threshold: 0.1 },
@@ -119,7 +123,7 @@ useIntersectionObserver(
onMounted(() => {
if (!chatStore.sessionsReady) {
void chatStore.fetchSessions(true);
void chatStore.fetchSessions(true, { minLoadingTime: 250 });
}
});
</script>
@@ -149,7 +153,7 @@ onMounted(() => {
@click="sidebar.toggleStatic()"
/>
</div>
<div :class="$style.items">
<div :class="$style.links">
<ChatSidebarLink
:to="{
name: CHAT_VIEW,
@@ -161,15 +165,22 @@ onMounted(() => {
@click="handleNewChatClick"
/>
<ChatSidebarLink
:to="{ name: CHAT_AGENTS_VIEW }"
:label="i18n.baseText('chatHub.sidebar.link.customAgents')"
:to="{ name: CHAT_PERSONAL_AGENTS_VIEW }"
:label="i18n.baseText('chatHub.sidebar.link.personalAgents')"
icon="message-square"
:active="route.name === CHAT_PERSONAL_AGENTS_VIEW"
@click="sidebar.toggleOpen(false)"
/>
<ChatSidebarLink
:to="{ name: CHAT_WORKFLOW_AGENTS_VIEW }"
:label="i18n.baseText('chatHub.sidebar.link.workflowAgents')"
icon="robot"
:active="route.name === CHAT_AGENTS_VIEW"
:active="route.name === CHAT_WORKFLOW_AGENTS_VIEW"
@click="sidebar.toggleOpen(false)"
/>
</div>
<N8nScrollArea as-child type="scroll">
<div :class="$style.items">
<div :class="$style.historySections">
<div v-if="!readyToShowSessions" :class="$style.group">
<SkeletonMenuItem v-for="i in 10" :key="`loading-${i}`" />
</div>
@@ -239,7 +250,14 @@ onMounted(() => {
margin-top: -4px;
}
.items {
.links {
display: flex;
flex-direction: column;
padding: 0 var(--spacing--xs) var(--spacing--sm) var(--spacing--xs);
gap: 1px;
}
.historySections {
display: flex;
flex-direction: column;
padding: 0 var(--spacing--xs) var(--spacing--sm) var(--spacing--xs);
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, useCssModule, useTemplateRef } from 'vue';
import { N8nNavigationDropdown, N8nIcon, N8nButton, N8nText, N8nAvatar } from '@n8n/design-system';
import { N8nNavigationDropdown, N8nIcon, N8nButton, N8nText } from '@n8n/design-system';
import { type ComponentProps } from 'vue-component-type-helpers';
import { PROVIDER_CREDENTIAL_TYPE_MAP, chatHubLLMProviderSchema } from '@n8n/api-types';
import type {
@@ -24,10 +24,12 @@ import { useUIStore } from '@/app/stores/ui.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import {
personalAgentDefaultIcon,
flattenModel,
fromStringToModel,
isLlmProviderModel,
stringifyModel,
workflowAgentDefaultIcon,
} from '@/features/ai/chatHub/chat.utils';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useSettingsStore } from '@/app/stores/settings.store';
@@ -100,22 +102,61 @@ const menu = computed(() => {
const fullNamesMap: Record<string, string> = {};
if (includeCustomAgents) {
const customAgents = isLoading
? []
: [...agents['custom-agent'].models, ...agents['n8n'].models].map((agent) => {
// Create submenu items for each project
const n8nAgentsSubmenu: (typeof N8nNavigationDropdown)['menu'] = [];
if (isLoading) {
n8nAgentsSubmenu.push({
id: 'loading',
title: i18n.baseText('generic.loadingEllipsis'),
disabled: true,
});
} else if (agents.n8n.models.length === 0) {
n8nAgentsSubmenu.push({
id: 'no-agents',
title: i18n.baseText('chatHub.workflowAgents.empty.noAgents'),
disabled: true,
});
} else {
n8nAgentsSubmenu.push(
...agents.n8n.models.map((agent) => {
const id = stringifyModel(agent.model);
fullNamesMap[id] = agent.name;
return {
id,
icon: agent.icon ?? workflowAgentDefaultIcon,
iconSize: 'large',
title: truncateBeforeLast(agent.name, MAX_AGENT_NAME_CHARS_MENU),
disabled: false,
description: agent.description
? truncateBeforeLast(agent.description, 200, 0)
: undefined,
};
}),
);
}
const customAgents = isLoading
? []
: agents['custom-agent'].models.map((agent) => {
const id = stringifyModel(agent.model);
fullNamesMap[id] = agent.name;
return {
id,
icon: agent.icon ?? personalAgentDefaultIcon,
iconSize: 'large',
title: truncateBeforeLast(agent.name, MAX_AGENT_NAME_CHARS_MENU),
disabled: false,
description: agent.description
? truncateBeforeLast(agent.description, 200, 0)
: undefined,
};
});
menuItems.push({
id: 'custom-agents',
title: i18n.baseText('chatHub.agent.customAgents'),
icon: 'robot',
title: i18n.baseText('chatHub.agent.personalAgents'),
icon: 'message-square',
iconSize: 'large',
iconMargin: false,
submenu: [
@@ -130,11 +171,23 @@ const menu = computed(() => {
{
id: NEW_AGENT_MENU_ID,
icon: 'plus',
iconSize: 'large',
title: i18n.baseText('chatHub.agent.newAgent'),
disabled: false,
},
],
});
menuItems.push({
id: 'n8n-agents',
title: i18n.baseText('chatHub.agent.workflowAgents'),
icon: 'robot',
iconSize: 'large',
iconMargin: false,
submenu: n8nAgentsSubmenu,
});
menuItems.push({ isDivider: true as const, id: 'agents-divider' });
}
for (const provider of chatHubLLMProviderSchema.options) {
@@ -145,6 +198,7 @@ const menu = computed(() => {
const configureMenu = {
id: `${provider}::configure`,
icon: 'settings' as const,
iconSize: 'large' as const,
title: i18n.baseText('chatHub.agent.configureCredentials'),
disabled: false,
};
@@ -174,6 +228,7 @@ const menu = computed(() => {
theAgents.push({
name: model.displayName,
description: '',
icon: null,
model: {
provider,
model: model.model,
@@ -228,6 +283,7 @@ const menu = computed(() => {
{
id: `${provider}::add-model`,
icon: 'plus',
iconSize: 'large',
title: i18n.baseText('chatHub.agent.addModel'),
disabled: false,
} as const,
@@ -349,17 +405,10 @@ defineExpose({
:size="16"
:class="$style.menuIcon"
/>
<N8nAvatar
v-else-if="item.id.startsWith('n8n::') || item.id.startsWith('custom-agent::')"
:class="$style.avatarIcon"
:first-name="menu.fullNames[item.id] || item.title"
size="xsmall"
/>
</template>
<N8nButton :class="$style.dropdownButton" type="secondary" :text="text">
<ChatAgentAvatar
v-if="selectedAgent"
:agent="selectedAgent"
:size="credentialsName || !isCredentialsRequired ? 'md' : 'sm'"
:class="$style.icon"
@@ -0,0 +1,73 @@
<template>
<div :class="$style.card">
<div :class="$style.avatar"></div>
<div :class="$style.content">
<div :class="[$style.skeleton, $style.title]"></div>
<div :class="[$style.skeleton, $style.description]"></div>
</div>
<div :class="[$style.skeleton, $style.actionButton]"></div>
</div>
</template>
<style lang="scss" module>
.card {
display: flex;
align-items: center;
gap: var(--spacing--sm);
padding: var(--spacing--sm);
background-color: var(--color--background--light-3);
border: var(--border);
border-radius: var(--radius--lg);
}
.skeleton {
background: var(--color--foreground);
animation: skeleton-pulse 1s ease-in-out infinite;
border-radius: var(--radius--sm);
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
flex-shrink: 0;
background: var(--color--foreground);
animation: skeleton-pulse 1s ease-in-out infinite;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
flex: 1;
min-width: 0;
}
.title {
height: 16px;
width: 40%;
}
.description {
height: 14px;
width: 60%;
}
.actionButton {
width: 24px;
height: 24px;
border-radius: var(--radius);
}
@keyframes skeleton-pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 0.3;
}
}
</style>
@@ -3,7 +3,8 @@ import type { ChatHubProvider } from '@n8n/api-types';
// Route and view identifiers
export const CHAT_VIEW = 'chat';
export const CHAT_CONVERSATION_VIEW = 'chat-conversation';
export const CHAT_AGENTS_VIEW = 'chat-agents';
export const CHAT_WORKFLOW_AGENTS_VIEW = 'chat-workflow-agents';
export const CHAT_PERSONAL_AGENTS_VIEW = 'chat-personal-agents';
export const CHAT_SETTINGS_VIEW = 'chat-settings';
export const CHAT_STORE = 'chatStore';
@@ -2,7 +2,8 @@ import { type FrontendModuleDescription } from '@/app/moduleInitializer/module.t
import {
CHAT_VIEW,
CHAT_CONVERSATION_VIEW,
CHAT_AGENTS_VIEW,
CHAT_WORKFLOW_AGENTS_VIEW,
CHAT_PERSONAL_AGENTS_VIEW,
TOOLS_SELECTOR_MODAL_KEY,
AGENT_EDITOR_MODAL_KEY,
CHAT_CREDENTIAL_SELECTOR_MODAL_KEY,
@@ -15,7 +16,10 @@ import { hasPermission } from '@/app/utils/rbac/permissions';
const ChatSidebar = async () => await import('@/features/ai/chatHub/components/ChatSidebar.vue');
const ChatView = async () => await import('@/features/ai/chatHub/ChatView.vue');
const ChatAgentsView = async () => await import('@/features/ai/chatHub/ChatAgentsView.vue');
const ChatWorkflowAgentsView = async () =>
await import('@/features/ai/chatHub/ChatWorkflowAgentsView.vue');
const ChatPersonalAgentsView = async () =>
await import('@/features/ai/chatHub/ChatPersonalAgentsView.vue');
const SettingsChatHubView = async () =>
await import('@/features/ai/chatHub/SettingsChatHubView.vue');
@@ -120,10 +124,26 @@ export const ChatModule: FrontendModuleDescription = {
},
},
{
name: CHAT_AGENTS_VIEW,
path: '/home/chat/agents',
name: CHAT_WORKFLOW_AGENTS_VIEW,
path: '/home/chat/workflow-agents',
components: {
default: ChatAgentsView,
default: ChatWorkflowAgentsView,
sidebar: ChatSidebar,
},
meta: {
middleware: ['authenticated'],
getProperties() {
return {
feature: 'chat-hub',
};
},
},
},
{
name: CHAT_PERSONAL_AGENTS_VIEW,
path: '/home/chat/personal-agents',
components: {
default: ChatPersonalAgentsView,
sidebar: ChatSidebar,
},
meta: {
@@ -22,9 +22,10 @@ import { PROJECT_DATA_TABLES, DATA_TABLE_VIEW } from '@/features/core/dataTable/
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import {
CHAT_AGENTS_VIEW,
CHAT_CONVERSATION_VIEW,
CHAT_PERSONAL_AGENTS_VIEW,
CHAT_VIEW,
CHAT_WORKFLOW_AGENTS_VIEW,
} from '@/features/ai/chatHub/constants';
export function useCommandBar() {
@@ -203,7 +204,8 @@ export function useCommandBar() {
return evaluationViewGroups;
case CHAT_VIEW:
case CHAT_CONVERSATION_VIEW:
case CHAT_AGENTS_VIEW:
case CHAT_PERSONAL_AGENTS_VIEW:
case CHAT_WORKFLOW_AGENTS_VIEW:
return chatHubViewGroups;
default:
return fallbackViewCommands;