feat(core): Add Chat Trigger toggle that controls whether a WF shows up on chat hub (no-changelog) (#21267)

This commit is contained in:
Jaakko Husso
2025-10-29 13:58:22 +02:00
committed by GitHub
parent e356d546a1
commit 516fd84656
20 changed files with 394 additions and 189 deletions
+12 -7
View File
@@ -33,31 +33,26 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<
*/
const openAIModelSchema = z.object({
provider: z.literal('openai'),
name: z.string(),
model: z.string(),
});
const anthropicModelSchema = z.object({
provider: z.literal('anthropic'),
name: z.string(),
model: z.string(),
});
const googleModelSchema = z.object({
provider: z.literal('google'),
name: z.string(),
model: z.string(),
});
const n8nModelSchema = z.object({
provider: z.literal('n8n'),
name: z.string(),
workflowId: z.string(),
});
const chatAgentSchema = z.object({
provider: z.literal('custom-agent'),
name: z.string(),
agentId: z.string(),
});
@@ -69,6 +64,11 @@ export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [
chatAgentSchema,
]);
export type ChatHubOpenAIModel = z.infer<typeof openAIModelSchema>;
export type ChatHubAnthropicModel = z.infer<typeof anthropicModelSchema>;
export type ChatHubGoogleModel = z.infer<typeof googleModelSchema>;
export type ChatHubN8nModel = z.infer<typeof n8nModelSchema>;
export type ChatHubCustomAgentModel = z.infer<typeof chatAgentSchema>;
export type ChatHubConversationModel = z.infer<typeof chatHubConversationModelSchema>;
/**
@@ -81,13 +81,19 @@ export const chatModelsRequestSchema = z.object({
export type ChatModelsRequest = z.infer<typeof chatModelsRequestSchema>;
export interface ChatModelDto {
model: ChatHubConversationModel;
name: string;
description: string | null;
}
/**
* Response type for fetching available chat models
*/
export type ChatModelsResponse = Record<
ChatHubProvider,
{
models: ChatHubConversationModel[];
models: ChatModelDto[];
error?: string;
}
>;
@@ -135,7 +141,6 @@ export class ChatHubUpdateConversationRequest extends Z.class({
model: z.string().max(64).optional(),
workflowId: z.string().max(36).optional(),
agentId: z.string().uuid().optional(),
agentName: z.string().max(128).optional(),
}) {}
export type ChatHubMessageType = 'human' | 'ai' | 'system' | 'tool' | 'generic';
+6
View File
@@ -8,6 +8,12 @@ export type * from './api-keys';
export type * from './community-node-types';
export {
chatHubConversationModelSchema,
type ChatModelDto,
type ChatHubOpenAIModel,
type ChatHubAnthropicModel,
type ChatHubGoogleModel,
type ChatHubN8nModel,
type ChatHubCustomAgentModel,
type ChatHubConversationModel,
chatHubProviderSchema,
type ChatHubProvider,
@@ -756,7 +756,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
);
}
async findWorkflowsWithNodeType(nodeTypes: string[]) {
async findWorkflowsWithNodeType(nodeTypes: string[], includeNodes: boolean = false) {
if (!nodeTypes?.length) return [];
const qb = this.createQueryBuilder('workflow');
@@ -766,8 +766,15 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
this.globalConfig.database.type,
);
const workflows: Array<{ id: string; name: string; active: boolean }> = await qb
.select(['workflow.id', 'workflow.name', 'workflow.active'])
const workflows: Array<
Pick<WorkflowEntity, 'id' | 'name' | 'active'> & Partial<Pick<WorkflowEntity, 'nodes'>>
> = await qb
.select([
'workflow.id',
'workflow.name',
'workflow.active',
...(includeNodes ? ['workflow.nodes'] : []),
])
.where(whereClause, parameters)
.getMany();
@@ -219,8 +219,8 @@ export class ChatTrigger extends Node {
icon: 'fa:comments',
iconColor: 'black',
group: ['trigger'],
version: [1, 1.1, 1.2, 1.3],
defaultVersion: 1.3,
version: [1, 1.1, 1.2, 1.3, 1.4],
defaultVersion: 1.4,
description: 'Runs the workflow when an n8n generated webchat is submitted',
defaults: {
name: 'When chat message received',
@@ -392,6 +392,49 @@ export class ChatTrigger extends Node {
default: 'Hi there! 👋\nMy name is Nathan. How can I assist you today?',
description: 'Default messages shown at the start of the chat, one per line',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Make Available in n8n Chat',
name: 'availableInChat',
type: 'boolean',
default: false,
noDataExpression: true,
description: 'Whether to make the agent available in n8n Chat',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.4 } }],
},
},
},
{
displayName: 'Agent Name',
name: 'agentName',
type: 'string',
default: '',
noDataExpression: true,
description: 'The name of the agent on n8n Chat',
displayOptions: {
show: {
availableInChat: [true],
},
},
},
{
displayName: 'Agent Description',
name: 'agentDescription',
type: 'string',
typeOptions: {
rows: 2,
},
default: '',
noDataExpression: true,
description: 'The description of the agent on n8n Chat',
displayOptions: {
show: {
availableInChat: [true],
},
},
},
{
displayName: 'Options',
name: 'options',
@@ -1,4 +1,6 @@
import { ChatModelsResponse } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { v4 as uuidv4 } from 'uuid';
@@ -6,9 +8,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { ChatHubAgent } from './chat-hub-agent.entity';
import { ChatHubAgentRepository } from './chat-hub-agent.repository';
import { ChatModelsResponse } from '@n8n/api-types';
import { ChatHubCredentialsService } from './chat-hub-credentials.service';
import type { User } from '@n8n/db';
@Service()
export class ChatHubAgentService {
@@ -23,9 +23,12 @@ export class ChatHubAgentService {
return {
models: agents.map((agent) => ({
provider: 'custom-agent',
name: agent.name,
agentId: agent.id,
description: agent.description ?? null,
model: {
provider: 'custom-agent',
agentId: agent.id,
},
})),
};
}
@@ -226,7 +226,7 @@ export class ChatHubController {
@Body payload: ChatHubUpdateConversationRequest,
): Promise<ChatHubConversationResponse> {
if (Object.keys(payload).length > 0) {
await this.chatService.updateSession(req.user.id, sessionId, payload);
await this.chatService.updateSession(req.user, sessionId, payload);
}
return await this.chatService.getConversation(req.user.id, sessionId);
@@ -47,6 +47,17 @@ import {
} from 'n8n-workflow';
import { v4 as uuidv4 } from 'uuid';
import { ActiveExecutions } from '@/active-executions';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExecutionService } from '@/executions/execution.service';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import { getBase } from '@/workflow-execute-additional-data';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { ChatHubAgentService } from './chat-hub-agent.service';
import { ChatHubCredentialsService } from './chat-hub-credentials.service';
import type { ChatHubMessage } from './chat-hub-message.entity';
@@ -63,17 +74,6 @@ import { ChatHubSessionRepository } from './chat-session.repository';
import { getMaxContextWindowTokens } from './context-limits';
import { interceptResponseWrites, createStructuredChunkAggregator } from './stream-capturer';
import { ActiveExecutions } from '@/active-executions';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExecutionService } from '@/executions/execution.service';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import { getBase } from '@/workflow-execute-additional-data';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
const providerNodeTypeMapping: Record<ChatHubLLMProvider, INodeTypeNameVersion> = {
openai: {
name: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
@@ -205,7 +205,7 @@ export class ChatHubService {
case 'google':
return await this.fetchGoogleModels(credentials, additionalData);
case 'n8n':
return await this.fetchCustomAgentWorkflows(user);
return await this.fetchAgentWorkflowsAsModels(user);
case 'custom-agent':
return await this.chatHubAgentService.getAgentsByUserIdAsModels(user.id);
}
@@ -214,7 +214,7 @@ export class ChatHubService {
private async fetchOpenAiModels(
credentials: INodeCredentials,
additionalData: IWorkflowExecuteAdditionalData,
): Promise<ChatModelsResponse[ChatHubProvider]> {
): Promise<ChatModelsResponse['openai']> {
const resourceLocatorResults = await this.nodeParametersService.getResourceLocatorResults(
'searchModels',
'parameters.model',
@@ -226,9 +226,12 @@ export class ChatHubService {
return {
models: resourceLocatorResults.results.map((result) => ({
provider: 'openai',
name: String(result.value),
model: String(result.value),
description: null,
model: {
provider: 'openai',
model: String(result.value),
},
})),
};
}
@@ -236,7 +239,7 @@ export class ChatHubService {
private async fetchAnthropicModels(
credentials: INodeCredentials,
additionalData: IWorkflowExecuteAdditionalData,
): Promise<ChatModelsResponse[ChatHubProvider]> {
): Promise<ChatModelsResponse['anthropic']> {
const resourceLocatorResults = await this.nodeParametersService.getResourceLocatorResults(
'searchModels',
'parameters.model',
@@ -248,9 +251,12 @@ export class ChatHubService {
return {
models: resourceLocatorResults.results.map((result) => ({
provider: 'anthropic',
name: String(result.value),
model: String(result.value),
description: null,
model: {
provider: 'anthropic',
model: String(result.value),
},
})),
};
}
@@ -258,7 +264,7 @@ export class ChatHubService {
private async fetchGoogleModels(
credentials: INodeCredentials,
additionalData: IWorkflowExecuteAdditionalData,
): Promise<ChatModelsResponse[ChatHubProvider]> {
): Promise<ChatModelsResponse['google']> {
const results = await this.nodeParametersService.getOptionsViaLoadOptions(
{
// From Gemini node
@@ -308,27 +314,60 @@ export class ChatHubService {
return {
models: results.map((result) => ({
provider: 'google',
name: String(result.value),
model: String(result.value),
description: null,
model: {
provider: 'google',
model: String(result.value),
},
})),
};
}
private async fetchCustomAgentWorkflows(
user: User,
): Promise<ChatModelsResponse[ChatHubProvider]> {
private async fetchAgentWorkflowsAsModels(user: User): Promise<ChatModelsResponse['n8n']> {
const nodeTypes = [CHAT_TRIGGER_NODE_TYPE];
const workflows = await this.workflowService.getWorkflowsWithNodesIncluded(user, nodeTypes);
const workflows = await this.workflowService.getWorkflowsWithNodesIncluded(
user,
nodeTypes,
true,
);
return {
models: workflows
// Ensure the user has at least read access to the workflow
.filter((workflow) => workflow.scopes.includes('workflow:read'))
.filter((workflow) => workflow.active)
.map((workflow) => ({
provider: 'n8n',
name: workflow.name ?? 'Unnamed workflow',
workflowId: workflow.id,
})),
.flatMap((workflow) => {
const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
if (!chatTrigger) {
return [];
}
if (chatTrigger.parameters.availableInChat !== true) {
return [];
}
const name =
typeof chatTrigger.parameters.agentName === 'string' &&
chatTrigger.parameters.agentName.length > 0
? chatTrigger.parameters.agentName
: workflow.name;
return [
{
name: name ?? 'Unknown Agent',
description:
typeof chatTrigger.parameters.agentDescription === 'string' &&
chatTrigger.parameters.agentDescription.length > 0
? chatTrigger.parameters.agentDescription
: null,
model: {
provider: 'n8n',
workflowId: workflow.id,
},
},
];
}),
};
}
@@ -443,10 +482,7 @@ export class ChatHubService {
const { sessionId, messageId, message, model, credentials, previousMessageId } = payload;
const provider = payload.model.provider;
const selectedModel: ModelWithCredentials = {
...payload.model,
credentialId: provider !== 'n8n' ? this.pickCredentialId(provider, credentials) : null,
};
const selectedModel = this.getModelWithCredentials(model, credentials);
const workflow = await this.messageRepository.manager.transaction(async (trx) => {
const session = await this.getChatSession(user, sessionId, selectedModel, true, trx);
@@ -507,7 +543,7 @@ export class ChatHubService {
const { sessionId, editId, messageId, message, model, credentials } = payload;
const provider = payload.model.provider;
const selectedModel: ModelWithCredentials = this.getModelWithCredentials(model, credentials);
const selectedModel = this.getModelWithCredentials(model, credentials);
const workflow = await this.messageRepository.manager.transaction(async (trx) => {
const session = await this.getChatSession(user, sessionId, selectedModel, true, trx);
@@ -593,13 +629,7 @@ export class ChatHubService {
const { sessionId, retryId, model, credentials } = payload;
const provider = payload.model.provider;
const selectedModel: ModelWithCredentials = {
...payload.model,
credentialId:
payload.model.provider !== 'n8n'
? this.pickCredentialId(payload.model.provider, payload.credentials)
: null,
};
const selectedModel = this.getModelWithCredentials(model, credentials);
const { workflow, retryOfMessageId, previousMessageId } =
await this.messageRepository.manager.transaction(async (trx) => {
@@ -739,7 +769,6 @@ export class ChatHubService {
const model: ChatHubConversationModel = {
provider: agent.provider,
model: agent.model,
name: agent.model,
};
const credentials: INodeCredentials = {
@@ -797,11 +826,6 @@ export class ChatHubService {
);
}
// const agents = workflowEntity.nodes.filter((node) => node.type === AGENT_LANGCHAIN_NODE_TYPE);
// if (agents.length !== 1) {
// throw new BadRequestError('Workflow must have exactly one AI Agent node');
// }
return {
workflowData: {
...workflowEntity,
@@ -1344,17 +1368,10 @@ export class ChatHubService {
credentials: INodeCredentials,
) {
const provider = selectedModel.provider;
const agentId = selectedModel?.provider === 'custom-agent' ? selectedModel.agentId : undefined;
const agentName =
selectedModel?.provider === 'custom-agent' && 'name' in selectedModel
? selectedModel.name
: undefined;
const modelWithCredentials: ModelWithCredentials = {
...selectedModel,
credentialId: provider !== 'n8n' ? this.pickCredentialId(provider, credentials) : null,
agentId,
agentName, // used for sessions
};
return modelWithCredentials;
@@ -1374,11 +1391,49 @@ export class ChatHubService {
throw new NotFoundError('Chat session not found');
}
let agentName: string | undefined = undefined;
if (selectedModel?.provider === 'custom-agent') {
// Find the agent to get its name
const agent = await this.chatHubAgentService.getAgentById(selectedModel.agentId!, user.id);
if (!agent) {
throw new BadRequestError('Agent not found for chat session initialization');
}
agentName = agent.name;
}
if (selectedModel?.provider === 'n8n') {
// Find the workflow to get its name
const workflow = await this.workflowFinderService.findWorkflowForUser(
selectedModel.workflowId!,
user,
['workflow:read'],
{ includeTags: false, includeParentFolder: false },
);
if (!workflow) {
throw new BadRequestError('Workflow not found for chat session initialization');
}
const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
if (!chatTrigger) {
throw new BadRequestError(
'Chat trigger not found in workflow for chat session initialization',
);
}
agentName =
typeof chatTrigger.parameters.agentName === 'string' &&
chatTrigger.parameters.agentName.length > 0
? chatTrigger.parameters.agentName
: workflow.name;
}
return await this.sessionRepository.createChatSession(
{
id: sessionId,
ownerId: user.id,
title: 'New Chat',
agentName,
...selectedModel,
},
trx,
@@ -1571,7 +1626,7 @@ export class ChatHubService {
* Updates a session with the provided fields
*/
async updateSession(
userId: string,
user: User,
sessionId: ChatSessionId,
updates: {
title?: string;
@@ -1580,23 +1635,68 @@ export class ChatHubService {
model?: string | null;
workflowId?: string | null;
agentId?: string | null;
agentName?: string;
agentName?: string | null;
},
) {
const session = await this.sessionRepository.getOneById(sessionId, userId);
const session = await this.sessionRepository.getOneById(sessionId, user.id);
if (!session) {
throw new NotFoundError('Session not found');
}
if (updates.workflowId) {
// Validate the workflow exists and is accessible
const workflow = await this.workflowFinderService.findWorkflowForUser(
updates.workflowId,
user,
['workflow:read'],
{ includeTags: false, includeParentFolder: false },
);
if (!workflow) {
throw new BadRequestError('Workflow not found');
}
const chatTriggers = workflow.nodes.filter((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
if (chatTriggers.length !== 1) {
throw new BadRequestError('Workflow must have exactly one chat trigger');
}
const chatTrigger = chatTriggers[0];
updates.agentName =
typeof chatTrigger.parameters.agentName === 'string' &&
chatTrigger.parameters.agentName.length > 0
? chatTrigger.parameters.agentName
: workflow.name;
}
if (updates.agentId) {
// Validate the agent exists and is accessible
const agent = await this.chatHubAgentService.getAgentById(updates.agentId, user.id);
if (!agent) {
throw new BadRequestError('Agent not found');
}
updates.agentName = agent.name;
}
if (updates.provider === 'n8n') {
// n8n provider only stores workflowId
updates.model = null;
updates.credentialId = null;
updates.agentId = null;
} else if (updates.provider) {
// Other providers can't have workflowId
} else if (updates.provider === 'custom-agent') {
// custom-agent provider only stores agentId & Agent name
updates.model = null;
updates.credentialId = null;
updates.workflowId = null;
} else if (updates.provider) {
updates.workflowId = null;
updates.agentId = null;
updates.agentName = null;
}
return await this.sessionRepository.updateChatSession(sessionId, updates);
@@ -12,7 +12,6 @@ export interface ModelWithCredentials {
workflowId?: string;
credentialId: string | null;
agentId?: string;
agentName?: string;
name?: string;
}
+14 -8
View File
@@ -587,8 +587,11 @@ export class WorkflowService {
);
}
async getWorkflowsWithNodesIncluded(user: User, nodeTypes: string[]) {
const foundWorkflows = await this.workflowRepository.findWorkflowsWithNodeType(nodeTypes);
async getWorkflowsWithNodesIncluded(user: User, nodeTypes: string[], includeNodes = false) {
const foundWorkflows = await this.workflowRepository.findWorkflowsWithNodeType(
nodeTypes,
includeNodes,
);
let { workflows } = await this.workflowRepository.getManyAndCount(
foundWorkflows.map((w) => w.id),
@@ -598,13 +601,16 @@ export class WorkflowService {
workflows = await this.processSharedWorkflows(workflows);
}
workflows = await this.addUserScopes(workflows, user);
const withScopes = await this.addUserScopes(workflows, user);
this.cleanupSharedField(workflows);
this.cleanupSharedField(withScopes);
return workflows.map((workflow) => ({
resourceType: 'workflow',
...workflow,
}));
return withScopes.map((workflow) => {
const nodes = includeNodes
? (foundWorkflows.find((w) => w.id === workflow.id)?.nodes ?? [])
: undefined;
return { resourceType: 'workflow', ...workflow, ...(includeNodes ? { nodes } : {}) };
});
}
}
@@ -19,7 +19,7 @@ import AgentEditorModal from '@/features/ai/chatHub/components/AgentEditorModal.
import ChatAgentCard from '@/features/ai/chatHub/components/ChatAgentCard.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 type { ChatModelDto, ChatHubConversationModel } from '@n8n/api-types';
import { filterAndSortAgents } from '@/features/ai/chatHub/chat.utils';
import type { ChatAgentFilter } from '@/features/ai/chatHub/chat.types';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
@@ -47,16 +47,15 @@ const { credentialsByProvider } = useChatCredentials(usersStore.currentUserId ??
const allModels = computed(() =>
chatStore.agents
.map<ChatHubConversationModel>((agent) => ({
provider: 'custom-agent',
agentId: agent.id,
.map<ChatModelDto>((agent) => ({
model: {
provider: 'custom-agent',
agentId: agent.id,
},
name: agent.name,
description: agent.description,
}))
.concat(
(chatStore.models?.n8n?.models ?? []).flatMap((model) =>
model.provider === 'n8n' ? [{ ...model, type: 'n8n-workflow' }] : [],
),
),
.concat(chatStore.models?.n8n.models ?? []),
);
const models = computed(() => {
@@ -65,7 +64,7 @@ const models = computed(() => {
agentFilter.value,
chatStore.agents,
workflowsStore.workflowsById, // TODO: ensure workflows are fetched
);
).map((modelDto) => modelDto.model);
});
const providerOptions = [
@@ -193,7 +192,7 @@ onMounted(async () => {
<ChatAgentCard
v-for="model in models"
:key="`${model.provider}::${model.provider === 'custom-agent' ? model.agentId : model.provider === 'n8n' ? model.workflowId : model.model}`"
:model="model"
:model="chatStore.getModel(model)!"
:agents="chatStore.agents"
:workflows-by-id="workflowsStore.workflowsById"
@edit="handleEditAgent(model)"
@@ -85,43 +85,43 @@ const selectedModel = computed<ChatHubConversationModel | null>(() => {
const n8nModel = chatStore.models?.n8n.models.find(
(m) =>
currentConversation.value &&
m.provider === 'n8n' &&
m.workflowId === currentConversation.value.workflowId,
m.model.provider === 'n8n' &&
m.model.workflowId === currentConversation.value.workflowId,
);
if (!n8nModel) {
return null;
}
model = n8nModel;
model = n8nModel.model;
} else if (currentConversation.value.provider === 'custom-agent') {
const agentModel = chatStore.models?.['custom-agent'].models.find(
(m) =>
currentConversation.value &&
m.provider === 'custom-agent' &&
m.agentId === currentConversation.value.agentId,
m.model.provider === 'custom-agent' &&
m.model.agentId === currentConversation.value.agentId,
);
if (!agentModel) {
return null;
}
model = agentModel;
model = agentModel.model;
} else {
const chatModel = chatStore.models?.[currentConversation.value.provider].models.find(
(m) =>
currentConversation.value &&
currentConversation.value?.provider !== 'n8n' &&
currentConversation.value?.provider !== 'custom-agent' &&
m.provider === currentConversation.value?.provider &&
m.model === currentConversation.value.model,
m.model.provider === currentConversation.value?.provider &&
m.model.model === currentConversation.value.model,
);
if (!chatModel) {
return null;
}
model = chatModel;
model = chatModel.model;
}
} else {
model = defaultModel.value;
@@ -274,7 +274,6 @@ watch(
await handleSelectModel({
provider: 'custom-agent',
agentId: agent.id,
name: agent.name,
});
}
return;
@@ -283,11 +282,11 @@ watch(
// Handle n8n workflow selection
if (typeof workflowId === 'string') {
const n8nModel = chatStore.models?.n8n?.models.find(
(m) => m.provider === 'n8n' && m.workflowId === workflowId,
(m) => m.model.provider === 'n8n' && m.model.workflowId === workflowId,
);
if (n8nModel) {
await handleSelectModel(n8nModel);
await handleSelectModel(n8nModel.model);
}
}
},
@@ -615,14 +615,17 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
): Promise<ChatHubConversationModel> {
const agent = await createAgentApi(rootStore.restApiContext, payload);
agents.value.push(agent);
const model = {
provider: 'custom-agent' as const,
agentId: agent.id,
const agentModel = {
model: {
provider: 'custom-agent' as const,
agentId: agent.id,
},
name: agent.name,
description: agent.description ?? null,
};
models.value?.['custom-agent'].models.push(model);
models.value?.['custom-agent'].models.push(agentModel);
return model;
return agentModel.model;
}
async function updateAgent(
@@ -654,8 +657,23 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
}
function getModel(identifier: ChatHubConversationModel) {
if (!models.value) return;
return models.value[identifier.provider].models.find((m) => {
if (identifier.provider === 'n8n') {
return m.model.provider === 'n8n' && m.model.workflowId === identifier.workflowId;
} else if (identifier.provider === 'custom-agent') {
return m.model.provider === 'custom-agent' && m.model.agentId === identifier.agentId;
} else {
return m.model.provider === identifier.provider && m.model.model === identifier.model;
}
});
}
return {
models,
getModel,
sessions,
agents,
currentEditingAgent,
@@ -4,6 +4,7 @@ import {
type ChatModelsResponse,
type ChatHubSessionDto,
type ChatHubAgentDto,
type ChatModelDto,
} from '@n8n/api-types';
import type { ChatMessage, GroupedConversations, ChatAgentFilter } from './chat.types';
import { CHAT_VIEW } from './constants';
@@ -14,7 +15,7 @@ export function findOneFromModelsResponse(
): ChatHubConversationModel | undefined {
for (const provider of chatHubProviderSchema.options) {
if (response[provider].models.length > 0) {
return response[provider].models[0];
return response[provider].models[0].model;
}
}
@@ -104,8 +105,6 @@ export function getAgentRoute(model: ChatHubConversationModel) {
export function restoreConversationModelFromMessageOrSession(
messageOrSession: ChatHubSessionDto | ChatMessage,
agents: ChatHubAgentDto[],
workflowsById: Partial<Record<string, IWorkflowDb>>,
): ChatHubConversationModel | null {
if (messageOrSession.provider === null) {
return null;
@@ -120,9 +119,6 @@ export function restoreConversationModelFromMessageOrSession(
return {
provider: 'custom-agent',
agentId: messageOrSession.agentId,
name:
agents.find((agent) => agent.id === messageOrSession.agentId)?.name ??
`Custom agent ${messageOrSession.agentId}`,
};
case 'n8n':
if (!messageOrSession.workflowId) {
@@ -132,9 +128,6 @@ export function restoreConversationModelFromMessageOrSession(
return {
provider: 'n8n',
workflowId: messageOrSession.workflowId,
name:
workflowsById[messageOrSession.workflowId]?.name ??
`n8n workflow ${messageOrSession.workflowId}`,
};
default:
if (messageOrSession.model === null) {
@@ -144,13 +137,12 @@ export function restoreConversationModelFromMessageOrSession(
return {
provider: messageOrSession.provider,
model: messageOrSession.model,
name: messageOrSession.model,
};
}
}
export function describeConversationModel(model: ChatHubConversationModel) {
switch (model.provider) {
export function describeConversationModel(model: ChatModelDto) {
switch (model.model.provider) {
case 'n8n':
return `n8n workflow ${model.name}`;
case 'custom-agent':
@@ -167,7 +159,7 @@ export function getTimestamp(
workflowsById: Partial<Record<string, IWorkflowDb>>,
): number | null {
if (model.provider === 'custom-agent') {
const agent = agents.find((a) => a.id === model.agentId);
const agent = agents.find((a) => model.provider === 'custom-agent' && a.id === model.agentId);
return agent?.[type] ? Date.parse(agent[type]) : null;
}
@@ -184,11 +176,11 @@ export function getTimestamp(
}
export function filterAndSortAgents(
models: ChatHubConversationModel[],
models: ChatModelDto[],
filter: ChatAgentFilter,
agents: ChatHubAgentDto[],
workflowsById: Partial<Record<string, IWorkflowDb>>,
): ChatHubConversationModel[] {
): ChatModelDto[] {
let filtered = models;
// Apply search filter
@@ -199,13 +191,13 @@ export function filterAndSortAgents(
// Apply provider filter
if (filter.provider !== '') {
filtered = filtered.filter((model) => model.provider === filter.provider);
filtered = filtered.filter((model) => model.model.provider === filter.provider);
}
// Apply sorting
filtered = [...filtered].sort((a, b) => {
const dateA = getTimestamp(a, filter.sortBy, agents, workflowsById);
const dateB = getTimestamp(b, filter.sortBy, agents, workflowsById);
const dateA = getTimestamp(a.model, filter.sortBy, agents, workflowsById);
const dateB = getTimestamp(b.model, filter.sortBy, agents, workflowsById);
// Sort by dates (newest first)
if (dateA && dateB) {
@@ -3,20 +3,27 @@ import { describeConversationModel } from '@/features/ai/chatHub/chat.utils';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { type ChatHubConversationModel, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
import { N8nAvatar, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { useChatStore } from '../chat.store';
import { computed } from 'vue';
defineProps<{
const props = defineProps<{
model: ChatHubConversationModel;
size: 'sm' | 'md' | 'lg';
tooltip?: boolean;
}>();
const chatStore = useChatStore();
const modelDto = computed(() => {
return chatStore.getModel(props.model) ?? null;
});
</script>
<template>
<N8nTooltip :show-after="100" placement="left" :disabled="!tooltip">
<template #content>{{ describeConversationModel(model) }}</template>
<template v-if="modelDto" #content>{{ describeConversationModel(modelDto) }}</template>
<N8nAvatar
v-if="model.provider === 'custom-agent'"
:first-name="model.name"
:first-name="modelDto?.name"
:size="size === 'lg' ? 'medium' : size === 'sm' ? 'xxsmall' : 'xsmall'"
/>
<N8nIcon
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { getAgentRoute, getTimestamp } from '@/features/ai/chatHub/chat.utils';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import type { ChatHubAgentDto, ChatHubConversationModel } from '@n8n/api-types';
import type { ChatHubAgentDto, ChatModelDto } from '@n8n/api-types';
import type { IWorkflowDb } from '@/Interface';
import { N8nIconButton, N8nText } from '@n8n/design-system';
import TimeAgo from '@/components/TimeAgo.vue';
@@ -9,7 +9,7 @@ import { computed } from 'vue';
import { RouterLink } from 'vue-router';
const { model, agents, workflowsById } = defineProps<{
model: ChatHubConversationModel;
model: ChatModelDto;
agents: ChatHubAgentDto[];
workflowsById: Partial<Record<string, IWorkflowDb>>;
}>();
@@ -20,9 +20,12 @@ const emit = defineEmits<{
}>();
const description = computed(() => {
if (model.provider === 'custom-agent') {
const agent = agents.find((a) => a.id === model.agentId);
if (model.model.provider === 'custom-agent') {
const agent = agents.find(
(a) => model.model.provider === 'custom-agent' && a.id === model.model.agentId,
);
// TODO: there's model.description that we could just use?
if (agent?.description) {
return agent.description;
}
@@ -31,13 +34,13 @@ const description = computed(() => {
return 'No description';
});
const updatedAt = computed(() => getTimestamp(model, 'updatedAt', agents, workflowsById));
const createdAt = computed(() => getTimestamp(model, 'createdAt', agents, workflowsById));
const updatedAt = computed(() => getTimestamp(model.model, 'updatedAt', agents, workflowsById));
const createdAt = computed(() => getTimestamp(model.model, 'createdAt', agents, workflowsById));
</script>
<template>
<RouterLink :to="getAgentRoute(model)" :class="$style.card">
<ChatAgentAvatar :model="model" size="lg" />
<RouterLink :to="getAgentRoute(model.model)" :class="$style.card">
<ChatAgentAvatar :model="model.model" size="lg" />
<div :class="$style.content">
<N8nText tag="h3" size="medium" bold :class="$style.title">
@@ -48,7 +51,7 @@ const createdAt = computed(() => getTimestamp(model, 'createdAt', agents, workfl
</N8nText>
<div :class="$style.metadata">
<N8nText size="small" color="text-light">
{{ model.provider === 'n8n' ? 'n8n workflow' : 'Custom agent' }}
{{ model.model.provider === 'n8n' ? 'n8n workflow' : 'Custom agent' }}
</N8nText>
<N8nText v-if="updatedAt" size="small" color="text-light">
Last updated <TimeAgo :date="String(updatedAt)" />
@@ -59,7 +62,7 @@ const createdAt = computed(() => getTimestamp(model, 'createdAt', agents, workfl
</div>
</div>
<div v-if="model.provider === 'custom-agent'" :class="$style.actions">
<div v-if="model.model.provider === 'custom-agent'" :class="$style.actions">
<N8nIconButton
icon="pen"
type="tertiary"
@@ -12,8 +12,6 @@ import { computed, nextTick, onBeforeMount, ref, useTemplateRef, watch } from 'v
import VueMarkdown from 'vue-markdown-render';
import type { ChatMessage } from '../chat.types';
import ChatMessageActions from './ChatMessageActions.vue';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { restoreConversationModelFromMessageOrSession } from '@/features/ai/chatHub/chat.utils';
const { message, compact, isEditing, isStreaming, minHeight } = defineProps<{
@@ -36,8 +34,6 @@ const emit = defineEmits<{
}>();
const clipboard = useClipboard();
const chatStore = useChatStore();
const workflowsStore = useWorkflowsStore();
const editedText = ref('');
const textareaRef = useTemplateRef('textarea');
@@ -51,13 +47,7 @@ const speech = useSpeechSynthesis(messageContent, {
volume: 1,
});
const model = computed(() =>
restoreConversationModelFromMessageOrSession(
message,
chatStore.agents,
workflowsStore.workflowsById,
),
);
const model = computed(() => restoreConversationModelFromMessageOrSession(message));
async function handleCopy() {
const text = message.content;
@@ -5,6 +5,7 @@ import type { ChatHubConversationModel, ChatHubLLMProvider } from '@n8n/api-type
import { N8nIconButton, N8nInput, N8nText } from '@n8n/design-system';
import { useSpeechRecognition } from '@vueuse/core';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { useChatStore } from '../chat.store';
const { selectedModel, isMissingCredentials } = defineProps<{
isResponding: boolean;
@@ -21,6 +22,7 @@ const emit = defineEmits<{
const inputRef = useTemplateRef<HTMLElement>('inputRef');
const message = ref('');
const chatStore = useChatStore();
const toast = useToast();
@@ -30,12 +32,17 @@ const speechInput = useSpeechRecognition({
lang: navigator.language,
});
const selected = computed(() => {
if (!selectedModel || !chatStore.models) return null;
return chatStore.getModel(selectedModel) ?? null;
});
const placeholder = computed(() => {
if (!selectedModel) {
return 'Select a model';
}
return `Message ${selectedModel.name}`;
return `Message ${selected.value?.name ?? 'a model'}...`;
});
function onMic() {
@@ -6,8 +6,6 @@ import { type ChatHubSessionDto } from '@n8n/api-types';
import { N8nInput } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { useChatStore } from '../chat.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { restoreConversationModelFromMessageOrSession } from '@/features/ai/chatHub/chat.utils';
const { session, isRenaming, active } = defineProps<{
@@ -23,20 +21,12 @@ const emit = defineEmits<{
delete: [sessionId: string];
}>();
const chatStore = useChatStore();
const workflowsStore = useWorkflowsStore();
const input = useTemplateRef('input');
const editedLabel = ref('');
type SessionAction = 'rename' | 'delete';
const model = computed(() =>
restoreConversationModelFromMessageOrSession(
session,
chatStore.agents,
workflowsStore.workflowsById,
),
);
const model = computed(() => restoreConversationModelFromMessageOrSession(session));
const dropdownItems = computed<Array<ActionDropdownItem<SessionAction>>>(() => [
{
@@ -3,7 +3,12 @@ import { computed, ref, useTemplateRef, watch } from 'vue';
import { N8nNavigationDropdown, N8nIcon, N8nButton, N8nText, N8nAvatar } from '@n8n/design-system';
import { type ComponentProps } from 'vue-component-type-helpers';
import { PROVIDER_CREDENTIAL_TYPE_MAP, chatHubProviderSchema } from '@n8n/api-types';
import type { ChatHubProvider, ChatHubConversationModel, ChatHubLLMProvider } from '@n8n/api-types';
import type {
ChatHubProvider,
ChatHubConversationModel,
ChatHubLLMProvider,
ChatHubCustomAgentModel,
} from '@n8n/api-types';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { onClickOutside } from '@vueuse/core';
@@ -37,8 +42,8 @@ function handleSelectCredentials(provider: ChatHubProvider, id: string) {
emit('selectCredential', provider, id);
}
const chatStore = useChatStore();
const i18n = useI18n();
const chatStore = useChatStore();
const dropdownRef = useTemplateRef('dropdownRef');
const credentialSelectorProvider = ref<Exclude<ChatHubProvider, 'n8n' | 'custom-agent'> | null>(
null,
@@ -46,6 +51,11 @@ const credentialSelectorProvider = ref<Exclude<ChatHubProvider, 'n8n' | 'custom-
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const selectedModelDto = computed(() => {
if (!props.selectedModel || !chatStore.models) return null;
return chatStore.getModel(props.selectedModel) ?? null;
});
const credentialsName = computed(() =>
props.selectedModel
? credentialsStore.getCredentialById(props.credentials[props.selectedModel.provider] ?? '')
@@ -55,13 +65,13 @@ const credentialsName = computed(() =>
const menu = computed(() => {
const agents = chatStore.models?.['custom-agent'].models;
const agentOptions = (agents ?? [])
.filter((model) => 'agentId' in model)
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((agent) => ({
id: `agent::${agent.agentId}`,
title: agent.name,
disabled: false,
}));
const agentOptions = (agents ?? []).map<
ComponentProps<typeof N8nNavigationDropdown>['menu'][number]
>((agent) => ({
id: `agent::${(agent.model as ChatHubCustomAgentModel).agentId}`,
title: agent.name,
disabled: false,
}));
const agentMenu: ComponentProps<typeof N8nNavigationDropdown>['menu'][number] = {
id: 'custom-agents',
@@ -93,9 +103,14 @@ const menu = computed(() => {
const modelOptions =
models.length > 0
? models
.filter((model) => model.provider !== 'custom-agent')
.filter((model) => model.model.provider !== 'custom-agent')
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((model) => {
const identifier = model.provider === 'n8n' ? model.workflowId : model.model;
const identifier =
model.model.provider === 'n8n'
? model.model.workflowId
: model.model.provider !== 'custom-agent'
? model.model.model
: model.model.agentId;
return {
id: `${provider}::${identifier}`,
@@ -133,8 +148,8 @@ const menu = computed(() => {
});
const selectedLabel = computed(() => {
if (!props.selectedModel) return 'Select model';
return props.selectedModel.name;
if (!selectedModelDto.value) return 'Select model';
return selectedModelDto.value.name;
});
function openCredentialsSelectorOrCreate(provider: ChatHubLLMProvider) {
@@ -159,10 +174,12 @@ function onSelect(id: string) {
emit('createAgent');
} else {
const agents = chatStore.models?.['custom-agent'].models;
const selected = agents?.find((agent) => 'agentId' in agent && agent.agentId === value);
const selected = agents?.find(
(agent) => agent.model.provider === 'custom-agent' && agent.model.agentId === value,
);
if (selected) {
emit('change', selected);
emit('change', selected.model);
}
}
return;
@@ -184,14 +201,18 @@ function onSelect(id: string) {
const model = parsedProvider === 'n8n' ? null : identifier;
const workflowId = parsedProvider === 'n8n' ? identifier : null;
const selected = chatStore.models?.[parsedProvider].models
.filter((m) => m.provider !== 'custom-agent')
.find((m) => (m.provider === 'n8n' ? m.workflowId === workflowId : m.model === model));
.filter((m) => m.model.provider !== 'custom-agent')
.find((m) =>
m.model.provider === 'n8n'
? m.model.workflowId === workflowId
: m.model.provider !== 'custom-agent' && m.model.model === model,
);
if (!selected) {
return;
}
emit('change', selected);
emit('change', selected.model);
}
function handleCreateNewCredential(provider: ChatHubLLMProvider) {
@@ -246,8 +267,8 @@ defineExpose({
/>
<ChatAgentAvatar
v-if="selectedModel"
:model="selectedModel"
v-if="selectedModelDto"
:model="selectedModelDto.model"
:size="credentialsName ? 'md' : 'sm'"
:class="$style.icon"
/>
@@ -25,18 +25,20 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import { CHAT_TRIGGER_NODE_TYPE, KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import {
getMainAuthField,
getNodeAuthFields,
isAuthRelatedParameter,
} from '@/utils/nodeTypesUtils';
import { injectWorkflowState } from '@/composables/useWorkflowState';
import { useSettingsStore } from '@/stores/settings.store';
export function useNodeSettingsParameters() {
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const nodeTypesStore = useNodeTypesStore();
const settingsStore = useSettingsStore();
const telemetry = useTelemetry();
const nodeHelpers = useNodeHelpers();
const workflowHelpers = useWorkflowHelpers();
@@ -202,6 +204,14 @@ export function useNodeSettingsParameters() {
return false;
}
// Hide chat hub toggle on chat trigger when module isn't enabled.
// Remove this check when feature is generally available.
if (nodeType?.name === CHAT_TRIGGER_NODE_TYPE && parameter.name === 'availableInChat') {
if (!settingsStore.isChatFeatureEnabled) {
return false;
}
}
if (parameter[displayKey] === undefined) {
// If it is not defined no need to do a proper check
return true;