From a8bb6bc6ad4a042b7a73acb7d5f98eef40dc061e Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Fri, 24 Oct 2025 12:16:39 +0300 Subject: [PATCH] feat(core): Chatting with n8n agent workflows (no-changelog) (#21088) --- packages/@n8n/api-types/src/chat-hub.ts | 42 ++- packages/@n8n/api-types/src/index.ts | 1 + .../modules/chat-hub/chat-hub.controller.ts | 62 ++-- .../src/modules/chat-hub/chat-hub.service.ts | 329 +++++++++++++----- .../src/modules/chat-hub/chat-hub.types.ts | 12 +- .../src/modules/chat-hub/context-limits.ts | 6 +- .../src/features/ai/chatHub/ChatView.vue | 96 +++-- .../src/features/ai/chatHub/chat.store.ts | 2 +- .../src/features/ai/chatHub/chat.utils.ts | 2 +- .../components/ChatConversationHeader.vue | 8 +- .../ai/chatHub/components/ChatMessage.vue | 2 +- .../ai/chatHub/components/ChatPrompt.vue | 32 +- .../components/ChatSessionMenuItem.vue | 6 +- .../components/CredentialSelectorModal.vue | 8 +- .../ai/chatHub/components/ModelSelector.vue | 97 ++++-- .../src/features/ai/chatHub/constants.ts | 1 + packages/workflow/src/constants.ts | 1 + 17 files changed, 493 insertions(+), 214 deletions(-) diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 742a5e32518..7adbcc73fa0 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -4,14 +4,16 @@ import { Z } from 'zod-class'; /** * Supported AI model providers */ -export const chatHubProviderSchema = z.enum(['openai', 'anthropic', 'google']); +export const chatHubLLMProviderSchema = z.enum(['openai', 'anthropic', 'google']); +export type ChatHubLLMProvider = z.infer; +export const chatHubProviderSchema = z.enum([...chatHubLLMProviderSchema.options, 'n8n'] as const); export type ChatHubProvider = z.infer; /** * Map of providers to their credential types */ -export const PROVIDER_CREDENTIAL_TYPE_MAP: Record = { +export const PROVIDER_CREDENTIAL_TYPE_MAP: Record = { openai: 'openAiApi', anthropic: 'anthropicApi', google: 'googlePalmApi', @@ -20,12 +22,37 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record = { /** * Chat Hub conversation model configuration */ -export const chatHubConversationModelSchema = z.object({ - provider: chatHubProviderSchema, +const openAIModelSchema = z.object({ + provider: z.literal('openai'), + name: z.string(), model: z.string(), - workflowId: z.string().nullable().default(null), }); +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(), +}); + +export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [ + openAIModelSchema, + anthropicModelSchema, + googleModelSchema, + n8nModelSchema, +]); + export type ChatHubConversationModel = z.infer; /** @@ -43,7 +70,10 @@ export type ChatModelsRequest = z.infer; */ export type ChatModelsResponse = Record< ChatHubProvider, - { models: Array<{ name: string }>; error?: string } + { + models: ChatHubConversationModel[]; + error?: string; + } >; export class ChatHubSendMessageRequest extends Z.class({ diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 2c4e45fa191..095ffade7ff 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -11,6 +11,7 @@ export { type ChatHubConversationModel, chatHubProviderSchema, type ChatHubProvider, + type ChatHubLLMProvider, type ChatHubMessageType, type ChatHubMessageStatus, PROVIDER_CREDENTIAL_TYPE_MAP, diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index 726cfb40a72..f5a6116fc1a 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -18,14 +18,7 @@ import { strict as assert } from 'node:assert'; import { ChatHubService } from './chat-hub.service'; import { ChatModelsRequestDto } from './dto/chat-models-request.dto'; -/* eslint-disable @typescript-eslint/naming-convention */ -const JSONL_STREAM_HEADERS = { - 'Content-Type': 'application/json-lines; charset=utf-8', - 'Transfer-Encoding': 'chunked', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', -}; -/* eslint-enable @typescript-eslint/naming-convention */ +import { ResponseError } from '@/errors/response-errors/abstract/response.error'; @RestController('/chat') export class ChatHubController { @@ -70,9 +63,6 @@ export class ChatHubController { res: Response, @Body payload: ChatHubSendMessageRequest, ) { - res.writeHead(200, JSONL_STREAM_HEADERS); - res.flushHeaders(); - this.logger.debug(`Chat send request received: ${JSON.stringify(payload)}`); try { @@ -80,21 +70,25 @@ export class ChatHubController { ...payload, userId: req.user.id, }); - } catch (executionError: unknown) { - assert(executionError instanceof Error); + } catch (error: unknown) { + assert(error instanceof Error); - this.logger.error(`Error in chat send endpoint: ${executionError}`); + this.logger.error(`Error in chat send endpoint: ${error}`); if (!res.headersSent) { + if (error instanceof ResponseError) { + throw error; + } + res.status(500).json({ code: 500, - message: executionError.message, + message: error.message, }); } else if (!res.writableEnded) { res.write( JSON.stringify({ type: 'error', - content: executionError.message, + content: error.message, }) + '\n', ); res.flush(); @@ -113,9 +107,6 @@ export class ChatHubController { @Param('messageId') editId: ChatMessageId, @Body payload: ChatHubEditMessageRequest, ) { - res.writeHead(200, JSONL_STREAM_HEADERS); - res.flushHeaders(); - this.logger.debug(`Chat edit request received: ${JSON.stringify(payload)}`); try { @@ -125,22 +116,25 @@ export class ChatHubController { editId, userId: req.user.id, }); - } catch (executionError: unknown) { - assert(executionError instanceof Error); + } catch (error: unknown) { + assert(error instanceof Error); - this.logger.error(`Error in chat edit endpoint: ${executionError}`); + this.logger.error(`Error in chat edit endpoint: ${error}`); if (!res.headersSent) { + if (error instanceof ResponseError) { + throw error; + } + res.status(500).json({ code: 500, - message: executionError.message, + message: error.message, }); } else if (!res.writableEnded) { res.write( JSON.stringify({ type: 'error', - content: executionError.message, - id: payload.replyId, + content: error.message, }) + '\n', ); res.flush(); @@ -159,9 +153,6 @@ export class ChatHubController { @Param('messageId') retryId: ChatMessageId, @Body payload: ChatHubRegenerateMessageRequest, ) { - res.writeHead(200, JSONL_STREAM_HEADERS); - res.flushHeaders(); - this.logger.debug(`Chat retry request received: ${JSON.stringify(payload)}`); try { @@ -171,22 +162,25 @@ export class ChatHubController { retryId, userId: req.user.id, }); - } catch (executionError: unknown) { - assert(executionError instanceof Error); + } catch (error: unknown) { + assert(error instanceof Error); - this.logger.error(`Error in chat retry endpoint: ${executionError}`); + this.logger.error(`Error in chat retry endpoint: ${error}`); if (!res.headersSent) { + if (error instanceof ResponseError) { + throw error; + } + res.status(500).json({ code: 500, - message: executionError.message, + message: error.message, }); } else if (!res.writableEnded) { res.write( JSON.stringify({ type: 'error', - content: executionError.message, - id: payload.replyId, + content: error.message, }) + '\n', ); res.flush(); diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index dfd3645594c..79ca6efb23f 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -1,10 +1,10 @@ import { PROVIDER_CREDENTIAL_TYPE_MAP, type ChatHubProvider, + type ChatHubLLMProvider, type ChatModelsResponse, type ChatHubConversationsResponse, type ChatHubConversationResponse, - chatHubProviderSchema, ChatHubMessageDto, type ChatMessageId, type ChatSessionId, @@ -41,6 +41,7 @@ import { type IRun, jsonParse, StructuredChunk, + RESPOND_TO_CHAT_NODE_TYPE, } from 'n8n-workflow'; import { v4 as uuidv4 } from 'uuid'; @@ -53,6 +54,8 @@ 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 type { ChatHubMessage } from './chat-hub-message.entity'; import { CONVERSATION_TITLE_GENERATION_PROMPT } from './chat-hub.constants'; @@ -68,7 +71,7 @@ import { ChatHubSessionRepository } from './chat-session.repository'; import { getMaxContextWindowTokens } from './context-limits'; import { captureResponseWrites } from './stream-capturer'; -const providerNodeTypeMapping: Record = { +const providerNodeTypeMapping: Record = { openai: { name: '@n8n/n8n-nodes-langchain.lmChatOpenAi', version: 1.2, @@ -93,6 +96,15 @@ const NODE_NAMES = { CLEAR_CHAT_MEMORY: 'Clear Chat Memory', } as const; +/* eslint-disable @typescript-eslint/naming-convention */ +const JSONL_STREAM_HEADERS = { + 'Content-Type': 'application/json-lines; charset=utf-8', + 'Transfer-Encoding': 'chunked', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', +}; +/* eslint-enable @typescript-eslint/naming-convention */ + @Service() export class ChatHubService { constructor( @@ -101,6 +113,8 @@ export class ChatHubService { private readonly nodeParametersService: DynamicNodeParametersService, private readonly executionRepository: ExecutionRepository, private readonly workflowExecutionService: WorkflowExecutionService, + private readonly workflowService: WorkflowService, + private readonly workflowFinderService: WorkflowFinderService, private readonly workflowRepository: WorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly activeExecutions: ActiveExecutions, @@ -111,48 +125,52 @@ export class ChatHubService { async getModels( user: User, - credentialIds: Record, + credentialIds: Record, ): Promise { const additionalData = await getBase({ userId: user.id }); + // The n8n provider is disabled for now. + // const providers = chatHubProviderSchema.options; + const providers = ['openai', 'anthropic', 'google'] as ChatHubLLMProvider[]; const allCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [ 'credential:read', ]); const responses = await Promise.all( - chatHubProviderSchema.options.map< - Promise<[ChatHubProvider, ChatModelsResponse[ChatHubProvider]]> - >(async (provider) => { - const credentialId = credentialIds[provider]; + providers.map>( + async (provider: ChatHubProvider) => { + const credentials: INodeCredentials = {}; - if (!credentialId) { - return [provider, { models: [] }]; - } + if (provider !== 'n8n') { + const credentialId = credentialIds[provider]; + if (!credentialId) { + return [provider, { models: [] }]; + } - // Ensure the user has the permission to read the credential - if (!allCredentials.some((credential) => credential.id === credentialId)) { - return [ - provider, - { models: [], error: 'Could not retrieve models. Verify credentials.' }, - ]; - } + // Ensure the user has the permission to read the credential + if (!allCredentials.some((credential) => credential.id === credentialId)) { + return [ + provider, + { models: [], error: 'Could not retrieve models. Verify credentials.' }, + ]; + } - try { - const credentials = { - [PROVIDER_CREDENTIAL_TYPE_MAP[provider]]: { name: '', id: credentialId }, - }; + credentials[PROVIDER_CREDENTIAL_TYPE_MAP[provider]] = { name: '', id: credentialId }; + } - return [ - provider, - await this.fetchModelsForProvider(provider, credentials, additionalData), - ]; - } catch { - return [ - provider, - { models: [], error: 'Could not retrieve models. Verify credentials.' }, - ]; - } - }), + try { + return [ + provider, + await this.fetchModelsForProvider(user, provider, credentials, additionalData), + ]; + } catch { + return [ + provider, + { models: [], error: 'Could not retrieve models. Verify credentials.' }, + ]; + } + }, + ), ); return responses.reduce( @@ -164,11 +182,13 @@ export class ChatHubService { openai: { models: [] }, anthropic: { models: [] }, google: { models: [] }, + n8n: { models: [] }, }, ); } private async fetchModelsForProvider( + user: User, provider: ChatHubProvider, credentials: INodeCredentials, additionalData: IWorkflowExecuteAdditionalData, @@ -180,6 +200,8 @@ export class ChatHubService { return await this.fetchAnthropicModels(credentials, additionalData); case 'google': return await this.fetchGoogleModels(credentials, additionalData); + case 'n8n': + return await this.fetchCustomAgentWorkflows(user); } } @@ -197,7 +219,11 @@ export class ChatHubService { ); return { - models: resourceLocatorResults.results.map((result) => ({ name: String(result.value) })), + models: resourceLocatorResults.results.map((result) => ({ + provider: 'openai', + name: String(result.value), + model: String(result.value), + })), }; } @@ -215,7 +241,11 @@ export class ChatHubService { ); return { - models: resourceLocatorResults.results.map((result) => ({ name: String(result.value) })), + models: resourceLocatorResults.results.map((result) => ({ + provider: 'anthropic', + name: String(result.value), + model: String(result.value), + })), }; } @@ -271,7 +301,28 @@ export class ChatHubService { ); return { - models: results.map((result) => ({ name: String(result.value) })), + models: results.map((result) => ({ + provider: 'google', + name: String(result.value), + model: String(result.value), + })), + }; + } + + private async fetchCustomAgentWorkflows( + user: User, + ): Promise { + const nodeTypes = [CHAT_TRIGGER_NODE_TYPE]; + const workflows = await this.workflowService.getWorkflowsWithNodesIncluded(user, nodeTypes); + + return { + models: workflows + .filter((workflow) => workflow.active) + .map((workflow) => ({ + provider: 'n8n', + name: workflow.name ?? 'Unnamed workflow', + workflowId: workflow.id, + })), }; } @@ -389,39 +440,26 @@ export class ChatHubService { provider: ChatHubProvider, credentials: INodeCredentials, ): string | null { + if (provider === 'n8n') { + return null; + } + return credentials[PROVIDER_CREDENTIAL_TYPE_MAP[provider]]?.id ?? null; } async sendHumanMessage(res: Response, user: User, payload: HumanMessagePayload) { const { sessionId, messageId, replyId, message } = payload; + const provider = payload.model.provider; const selectedModel: ModelWithCredentials = { ...payload.model, - credentialId: this.pickCredentialId(payload.model.provider, payload.credentials), + credentialId: + provider !== 'n8n' ? this.pickCredentialId(provider, payload.credentials) : null, }; const workflow = await this.messageRepository.manager.transaction(async (trx) => { - const credential = await this.ensureCredentials( - user, - payload.model, - payload.credentials, - trx, - ); const session = await this.getChatSession(user, sessionId, selectedModel, true, trx); - - // Ensure that the previous message exists in the session - if (payload.previousMessageId) { - const previousMessage = await this.messageRepository.getOneById( - payload.previousMessageId, - sessionId, - [], - trx, - ); - if (!previousMessage) { - throw new BadRequestError('The previous message does not exist in the session'); - } - } - + await this.ensurePreviousMessage(payload.previousMessageId, sessionId, trx); const messages = Object.fromEntries((session.messages ?? []).map((m) => [m.id, m])); const history = this.buildMessageHistory(messages, payload.previousMessageId); @@ -434,15 +472,15 @@ export class ChatHubService { trx, ); - return await this.createChatWorkflow( - session.id, - credential.projectId, - history, + if (provider !== 'n8n') { + return await this.prepareBaseChatWorkflow(user, payload, sessionId, history, message, trx); + } + + return await this.prepareCustomAgentWorkflow( + user, + sessionId, + payload.model.workflowId, message, - payload.credentials, - payload.model, - payload.previousMessageId === null, // generate title on receiving the first human message only - trx, ); }); @@ -457,7 +495,125 @@ export class ChatHubService { selectedModel, ); } finally { - await this.deleteChatWorkflow(workflow.workflowData.id); + if (provider !== 'n8n') { + await this.deleteChatWorkflow(workflow.workflowData.id); + } + } + } + + private async prepareBaseChatWorkflow( + user: User, + payload: HumanMessagePayload, + sessionId: ChatSessionId, + history: ChatHubMessage[], + message: string, + trx: EntityManager, + ) { + const credential = await this.ensureCredentials(user, payload.model, payload.credentials, trx); + + return await this.createChatWorkflow( + sessionId, + credential.projectId, + history, + message, + payload.credentials, + payload.model, + payload.previousMessageId === null, // generate title on receiving the first human message only + trx, + ); + } + + private async prepareCustomAgentWorkflow( + user: User, + sessionId: ChatSessionId, + workflowId: string, + message: string, + ) { + const workflowEntity = await this.workflowFinderService.findWorkflowForUser( + workflowId, + user, + ['workflow:read'], + { includeTags: false, includeParentFolder: false }, + ); + + if (!workflowEntity) { + throw new BadRequestError('Workflow not found'); + } + + const chatTriggers = workflowEntity.nodes.filter( + (node) => node.type === CHAT_TRIGGER_NODE_TYPE, + ); + + if (chatTriggers.length !== 1) { + throw new BadRequestError('Workflow must have exactly one chat trigger'); + } + + const chatResponseNodes = workflowEntity.nodes.filter( + (node) => node.type === RESPOND_TO_CHAT_NODE_TYPE, + ); + + if (chatResponseNodes.length > 0) { + throw new BadRequestError( + 'Respond to Chat nodes are not supported in custom agent workflows', + ); + } + + 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, + // Since this mechanism executes workflows as manual one-off executions + // we need to clear any pinData the WF might have. + // TODO: Implement a separate execution mode for chats to avoid such workarounds. + pinData: {}, + }, + triggerToStartFrom: { + name: chatTriggers[0].name, + data: { + startTime: Date.now(), + executionTime: 0, + executionIndex: 0, + executionStatus: 'success', + data: { + main: [ + [ + { + json: { + sessionId, + action: 'sendMessage', + chatInput: message, + }, + }, + ], + ], + }, + source: [null], + } satisfies ITaskData, + }, + }; + } + + private async ensurePreviousMessage( + previousMessageId: ChatMessageId | null, + sessionId: string, + trx?: EntityManager, + ) { + if (!previousMessageId) { + return; + } + + const previousMessage = await this.messageRepository.getOneById( + previousMessageId, + sessionId, + [], + trx, + ); + if (!previousMessage) { + throw new BadRequestError('The previous message does not exist in the session'); } } @@ -465,7 +621,10 @@ export class ChatHubService { const { sessionId, editId, messageId, replyId } = payload; const selectedModel: ModelWithCredentials = { ...payload.model, - credentialId: this.pickCredentialId(payload.model.provider, payload.credentials), + credentialId: + payload.model.provider !== 'n8n' + ? this.pickCredentialId(payload.model.provider, payload.credentials) + : null, }; const workflow = await this.messageRepository.manager.transaction(async (trx) => { @@ -542,10 +701,12 @@ export class ChatHubService { async regenerateAIMessage(res: Response, user: User, payload: RegenerateMessagePayload) { const { sessionId, retryId, replyId } = payload; - const selectedModel: ModelWithCredentials = { ...payload.model, - credentialId: this.pickCredentialId(payload.model.provider, payload.credentials), + credentialId: + payload.model.provider !== 'n8n' + ? this.pickCredentialId(payload.model.provider, payload.credentials) + : null, }; const { workflow, retryOfMessageId, previousMessageId } = @@ -659,15 +820,17 @@ export class ChatHubService { // Capture the streaming response as it's being generated to save // partial messages in the database when generation gets cancelled. - let message = ''; + let partialMessage = ''; const onChunk = (chunk: string) => { const data = jsonParse(chunk); if (data && data.type === 'item' && typeof data.content === 'string') { - message += data.content; + partialMessage += data.content; } }; const stream = captureResponseWrites(res, onChunk); + stream.writeHead(200, JSONL_STREAM_HEADERS); + stream.flushHeaders(); const { executionId } = await this.workflowExecutionService.executeManually( { @@ -688,7 +851,7 @@ export class ChatHubService { sessionId, executionId, previousMessageId, - message, + message: partialMessage, selectedModel, retryOfMessageId, status: 'running', @@ -712,7 +875,7 @@ export class ChatHubService { if (execution.status === 'canceled') { await this.messageRepository.updateChatMessage(replyId, { - content: message || 'Generation cancelled.', + content: partialMessage || 'Generation cancelled.', status: 'cancelled', }); return; @@ -737,13 +900,13 @@ export class ChatHubService { // TODO: We should consider can we just save the output from the captured stream always instead // of parsing it from execution data, which seems error prone, especially with custom workflows. // That could make handling multiple agents, multiple runes, tool executions etc easier...? - const output = this.getAIOutput(execution, NODE_NAMES.REPLY_AGENT); - if (!output) { - throw new OperationalError('No response generated'); - } + // const output = this.getAIOutput(execution, NODE_NAMES.REPLY_AGENT); + // if (!output) { + // throw new OperationalError('No response generated'); + // } await this.messageRepository.updateChatMessage(replyId, { - content: output, + content: partialMessage, status: 'success', }); @@ -795,7 +958,10 @@ export class ChatHubService { text: "={{ $('When chat message received').item.json.chatInput }}", options: { enableStreaming: true, - maxTokensFromMemory: getMaxContextWindowTokens(model.provider, model.model), + maxTokensFromMemory: + model.provider !== 'n8n' + ? getMaxContextWindowTokens(model.provider, model.model) + : undefined, }, }, type: AGENT_LANGCHAIN_NODE_TYPE, @@ -1050,8 +1216,13 @@ export class ChatHubService { private createModelNode( credentials: INodeCredentials, - { provider, model }: ChatHubConversationModel, + conversationModel: ChatHubConversationModel, ): INode { + if (conversationModel.provider === 'n8n') { + throw new OperationalError('Custom agent workflows do not require a model node'); + } + + const { provider, model } = conversationModel; const common = { position: [600, 500] as [number, number], id: uuidv4(), diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts index 61d7ef50e21..db492f2ce42 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.types.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -1,7 +1,15 @@ -import type { ChatHubConversationModel, ChatMessageId, ChatSessionId } from '@n8n/api-types'; +import type { + ChatHubConversationModel, + ChatHubProvider, + ChatMessageId, + ChatSessionId, +} from '@n8n/api-types'; import type { INodeCredentials } from 'n8n-workflow'; -export interface ModelWithCredentials extends ChatHubConversationModel { +export interface ModelWithCredentials { + provider: ChatHubProvider; + model?: string; + workflowId?: string; credentialId: string | null; } diff --git a/packages/cli/src/modules/chat-hub/context-limits.ts b/packages/cli/src/modules/chat-hub/context-limits.ts index aacf7b87261..7dc5fa6831c 100644 --- a/packages/cli/src/modules/chat-hub/context-limits.ts +++ b/packages/cli/src/modules/chat-hub/context-limits.ts @@ -1,4 +1,4 @@ -import type { ChatHubProvider } from '@n8n/api-types'; +import type { ChatHubLLMProvider } from '@n8n/api-types'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -8,7 +8,7 @@ import type { ChatHubProvider } from '@n8n/api-types'; // If the limit is set to 0, it means either the model has no defined limit or the information // is not availabl and no context window trimming is applied. Similarly, if the model used is // not listed, no limit is applied. -export const maxContextWindowTokens: Record> = { +export const maxContextWindowTokens: Record> = { openai: { 'chatgpt-4o-latest': 128000, 'codex-mini-latest': 0, @@ -140,7 +140,7 @@ export const maxContextWindowTokens: Record { const limit = maxContextWindowTokens[provider]?.[model] ?? 0; diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue index 024075efa15..12ed219d2c4 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue @@ -19,6 +19,7 @@ import { useUsersStore } from '@/features/settings/users/users.store'; import { chatHubConversationModelSchema, type ChatHubProvider, + type ChatHubLLMProvider, chatHubProviderSchema, PROVIDER_CREDENTIAL_TYPE_MAP, type ChatHubConversationModel, @@ -104,6 +105,10 @@ const selectedCredentials = useLocalStorage( const autoSelectCredentials = computed(() => Object.fromEntries( chatHubProviderSchema.options.map((provider) => { + if (provider === 'n8n') { + return [provider, null]; + } + const lastCreatedCredential = credentialsStore .getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[provider]) @@ -125,6 +130,22 @@ const credentialsId = computed(() => selectedModel.value ? mergedCredentials.value[selectedModel.value.provider] : undefined, ); +const modelRequiresCredentials = computed(() => { + if (!selectedModel.value) return false; + + return selectedModel.value?.provider !== 'n8n'; +}); + +const isMissingSelectedCredential = computed(() => { + if (!selectedModel.value) return false; + + if (!modelRequiresCredentials.value) { + return false; + } + + return !credentialsId.value; +}); + const editingMessageId = ref(); const didSubmitInCurrentSession = ref(false); const initialization = ref({ credentialsFetched: false, modelsFetched: false }); @@ -224,18 +245,28 @@ onMounted(async () => { }); function onSubmit(message: string) { - if (!message.trim() || chatStore.isResponding || !selectedModel.value || !credentialsId.value) { + if ( + !message.trim() || + chatStore.isResponding || + !selectedModel.value || + isMissingSelectedCredential.value + ) { return; } didSubmitInCurrentSession.value = true; - chatStore.sendMessage(sessionId.value, message, selectedModel.value, { - [PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: { - id: credentialsId.value, - name: '', - }, - }); + const credentials = {}; + if (selectedModel.value.provider !== 'n8n' && credentialsId.value) { + Object.assign(credentials, { + [PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: { + id: credentialsId.value, + name: '', + }, + }); + } + + chatStore.sendMessage(sessionId.value, message, selectedModel.value, credentials); inputRef.value?.setText(''); @@ -269,12 +300,20 @@ function handleEditMessage(message: ChatHubMessageDto) { const messageToEdit = message.revisionOfMessageId ?? message.id; - chatStore.editMessage(sessionId.value, messageToEdit, message.content, selectedModel.value, { - [PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: { - id: credentialsId.value, - name: '', - }, - }); + chatStore.editMessage( + sessionId.value, + messageToEdit, + message.content, + selectedModel.value, + selectedModel.value.provider === 'n8n' + ? {} + : { + [PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: { + id: credentialsId.value, + name: '', + }, + }, + ); editingMessageId.value = undefined; } @@ -290,27 +329,34 @@ function handleRegenerateMessage(message: ChatHubMessageDto) { const messageToRetry = message.retryOfMessageId ?? message.id; - chatStore.regenerateMessage(sessionId.value, messageToRetry, selectedModel.value, { - [PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: { - id: credentialsId.value, - name: '', - }, - }); + chatStore.regenerateMessage( + sessionId.value, + messageToRetry, + selectedModel.value, + selectedModel.value.provider === 'n8n' + ? {} + : { + [PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: { + id: credentialsId.value, + name: '', + }, + }, + ); } function handleSelectModel(selection: ChatHubConversationModel) { selectedModel.value = selection; } -function handleSelectCredentials(provider: ChatHubProvider, credentialsId: string) { - selectedCredentials.value = { ...selectedCredentials.value, [provider]: credentialsId }; +function handleSelectCredentials(provider: ChatHubProvider, id: string) { + selectedCredentials.value = { ...selectedCredentials.value, [provider]: id }; } function handleSwitchAlternative(messageId: string) { chatStore.switchAlternative(sessionId.value, messageId); } -function handleConfigureCredentials(provider: ChatHubProvider) { +function handleConfigureCredentials(provider: ChatHubLLMProvider) { const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider]; const existingCredentials = credentialsStore.getCredentialsByType(credentialType); @@ -327,7 +373,7 @@ function handleConfigureModel() { headerRef.value?.openModelSelector(); } -function handleCreateNewCredential(provider: ChatHubProvider) { +function handleCreateNewCredential(provider: ChatHubLLMProvider) { uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]); } @@ -352,7 +398,7 @@ function handleCreateNewCredential(provider: ChatHubProvider) { /> { name: 'User', content: message, provider: null, - model: model?.model ?? null, + model: model.provider === 'n8n' ? null : model.model, workflowId: null, executionId: null, status: 'success', diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts index 2269d6c64bb..3ad4c280342 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts @@ -11,7 +11,7 @@ export function findOneFromModelsResponse( ): ChatHubConversationModel | undefined { for (const provider of chatHubProviderSchema.options) { if (response[provider].models.length > 0) { - return { model: response[provider].models[0].name, provider, workflowId: null }; + return response[provider].models[0]; } } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue index c828162e0ee..3da6c712685 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue @@ -5,11 +5,7 @@ import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue'; import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState'; import { CHAT_VIEW } from '@/features/ai/chatHub/constants'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; -import { - type ChatHubConversationModel, - type ChatHubProvider, - type ChatSessionId, -} from '@n8n/api-types'; +import type { ChatHubConversationModel, ChatHubLLMProvider, ChatSessionId } from '@n8n/api-types'; import { N8nIconButton } from '@n8n/design-system'; import { computed, useTemplateRef } from 'vue'; import { useRouter } from 'vue-router'; @@ -21,7 +17,7 @@ const { selectedModel, credentials } = defineProps<{ const emit = defineEmits<{ selectModel: [ChatHubConversationModel]; - setCredentials: [provider: ChatHubProvider]; + setCredentials: [provider: ChatHubLLMProvider]; renameConversation: [id: ChatSessionId, title: string]; }>(); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.vue index 2814cf3187e..36a9217a664 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.vue @@ -48,7 +48,7 @@ const speech = useSpeechSynthesis(messageContent, { }); const credentialTypeName = computed(() => { - if (message.type !== 'ai' || !message.provider) { + if (message.type !== 'ai' || !message.provider || message.provider === 'n8n') { return null; } return PROVIDER_CREDENTIAL_TYPE_MAP[message.provider] ?? null; diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue index c5ce15aa4d5..62d32921ee8 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue @@ -1,26 +1,25 @@