mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 04:37:50 +08:00
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:
co-authored by
Jaakko Husso
parent
1b256ccc52
commit
b7c92e07c7
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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('')
|
||||
);
|
||||
}
|
||||
|
||||
+400
@@ -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',
|
||||
|
||||
+52
-7
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
+27
-109
@@ -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,
|
||||
};
|
||||
|
||||
+42
-12
@@ -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;
|
||||
|
||||
+58
-12
@@ -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"
|
||||
|
||||
+87
@@ -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">
|
||||
|
||||
+1
-1
@@ -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>>>(() => [
|
||||
|
||||
+27
-9
@@ -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: {
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user