feat(core): Chatting with n8n agent workflows (no-changelog) (#21088)

This commit is contained in:
Jaakko Husso
2025-10-24 12:16:39 +03:00
committed by GitHub
parent b45be8aa4c
commit a8bb6bc6ad
17 changed files with 493 additions and 214 deletions
+36 -6
View File
@@ -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<typeof chatHubLLMProviderSchema>;
export const chatHubProviderSchema = z.enum([...chatHubLLMProviderSchema.options, 'n8n'] as const);
export type ChatHubProvider = z.infer<typeof chatHubProviderSchema>;
/**
* Map of providers to their credential types
*/
export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<ChatHubProvider, string> = {
export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<ChatHubLLMProvider, string> = {
openai: 'openAiApi',
anthropic: 'anthropicApi',
google: 'googlePalmApi',
@@ -20,12 +22,37 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<ChatHubProvider, string> = {
/**
* 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<typeof chatHubConversationModelSchema>;
/**
@@ -43,7 +70,10 @@ export type ChatModelsRequest = z.infer<typeof chatModelsRequestSchema>;
*/
export type ChatModelsResponse = Record<
ChatHubProvider,
{ models: Array<{ name: string }>; error?: string }
{
models: ChatHubConversationModel[];
error?: string;
}
>;
export class ChatHubSendMessageRequest extends Z.class({
+1
View File
@@ -11,6 +11,7 @@ export {
type ChatHubConversationModel,
chatHubProviderSchema,
type ChatHubProvider,
type ChatHubLLMProvider,
type ChatHubMessageType,
type ChatHubMessageStatus,
PROVIDER_CREDENTIAL_TYPE_MAP,
@@ -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();
@@ -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<ChatHubProvider, INodeTypeNameVersion> = {
const providerNodeTypeMapping: Record<ChatHubLLMProvider, INodeTypeNameVersion> = {
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<ChatHubProvider, string | null>,
credentialIds: Record<ChatHubLLMProvider, string | null>,
): Promise<ChatModelsResponse> {
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<Promise<[ChatHubProvider, ChatModelsResponse[ChatHubProvider]]>>(
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<ChatModelsResponse>(
@@ -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<ChatModelsResponse[ChatHubProvider]> {
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<StructuredChunk>(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(),
@@ -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;
}
@@ -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<ChatHubProvider, Record<string, number>> = {
export const maxContextWindowTokens: Record<ChatHubLLMProvider, Record<string, number>> = {
openai: {
'chatgpt-4o-latest': 128000,
'codex-mini-latest': 0,
@@ -140,7 +140,7 @@ export const maxContextWindowTokens: Record<ChatHubProvider, Record<string, numb
};
export const getMaxContextWindowTokens = (
provider: ChatHubProvider,
provider: ChatHubLLMProvider,
model: string,
): number | undefined => {
const limit = maxContextWindowTokens[provider]?.[model] ?? 0;
@@ -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<CredentialsMap>(
const autoSelectCredentials = computed<CredentialsMap>(() =>
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<string>();
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]);
}
</script>
@@ -352,7 +398,7 @@ function handleCreateNewCredential(provider: ChatHubProvider) {
/>
<CredentialSelectorModal
v-if="credentialSelectorProvider"
v-if="credentialSelectorProvider && credentialSelectorProvider !== 'n8n'"
:key="credentialSelectorProvider"
:provider="credentialSelectorProvider"
:initial-value="mergedCredentials[credentialSelectorProvider] ?? null"
@@ -411,7 +457,7 @@ function handleCreateNewCredential(provider: ChatHubProvider) {
:class="$style.prompt"
:is-responding="chatStore.isResponding"
:selected-model="selectedModel"
:is-credentials-selected="!!credentialsId"
:is-missing-credentials="isMissingSelectedCredential"
@submit="onSubmit"
@stop="onStop"
@select-model="handleConfigureModel"
@@ -366,7 +366,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
name: 'User',
content: message,
provider: null,
model: model?.model ?? null,
model: model.provider === 'n8n' ? null : model.model,
workflowId: null,
executionId: null,
status: 'success',
@@ -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];
}
}
@@ -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];
}>();
@@ -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;
@@ -1,26 +1,25 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import type { ChatHubConversationModel, ChatHubProvider } from '@n8n/api-types';
import type { ChatHubConversationModel, ChatHubLLMProvider } from '@n8n/api-types';
import { N8nIconButton, N8nInput } from '@n8n/design-system';
import { useSpeechRecognition } from '@vueuse/core';
import { computed } from 'vue';
import { ref, useTemplateRef, watch } from 'vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
const { selectedModel } = defineProps<{
const { selectedModel, isMissingCredentials } = defineProps<{
isResponding: boolean;
selectedModel: ChatHubConversationModel | null;
isCredentialsSelected: boolean;
isMissingCredentials: boolean;
}>();
const emit = defineEmits<{
submit: [string];
stop: [];
selectModel: [];
setCredentials: [ChatHubProvider];
setCredentials: [ChatHubLLMProvider];
}>();
const inputRef = useTemplateRef('inputRef');
const inputRef = useTemplateRef<HTMLElement>('inputRef');
const message = ref('');
const toast = useToast();
@@ -36,9 +35,7 @@ const placeholder = computed(() => {
return 'Select a model';
}
const modelName = selectedModel.model;
return `Message ${modelName}`;
return `Message ${selectedModel.name}`;
});
function onMic() {
@@ -110,9 +107,12 @@ defineExpose({
Please <a href="" @click.prevent="emit('selectModel')">select a model</a> to start a
conversation
</div>
<div v-else-if="!isCredentialsSelected" :class="$style.callout">
<div v-else-if="isMissingCredentials" :class="$style.callout">
Please
<a href="" @click.prevent="emit('setCredentials', selectedModel.provider)">
<a
href=""
@click.prevent="emit('setCredentials', selectedModel.provider as ChatHubLLMProvider)"
>
set credentials
</a>
for {{ providerDisplayNames[selectedModel.provider] }} to start a conversation
@@ -126,7 +126,7 @@ defineExpose({
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="!isCredentialsSelected || !selectedModel"
:disabled="isMissingCredentials || !selectedModel"
@keydown="handleKeydownTextarea"
/>
@@ -136,7 +136,7 @@ defineExpose({
native-type="button"
type="secondary"
title="Attach"
:disabled="!isCredentialsSelected || !selectedModel || isResponding"
:disabled="isMissingCredentials || !selectedModel || isResponding"
icon="paperclip"
icon-size="large"
text
@@ -147,7 +147,7 @@ defineExpose({
native-type="button"
:title="speechInput.isListening.value ? 'Stop recording' : 'Voice input'"
type="secondary"
:disabled="!isCredentialsSelected || !selectedModel || isResponding"
:disabled="isMissingCredentials || !selectedModel || isResponding"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
@@ -156,7 +156,7 @@ defineExpose({
<N8nIconButton
v-if="!isResponding"
native-type="submit"
:disabled="!isCredentialsSelected || !selectedModel || !message.trim()"
:disabled="isMissingCredentials || !selectedModel || !message.trim()"
title="Send"
icon="arrow-up"
icon-size="large"
@@ -102,7 +102,11 @@ watch(
/>
</template>
<template #icon>
<N8nIcon v-if="session.provider === null" size="medium" icon="message-circle" />
<N8nIcon
v-if="session.provider === null || session.provider === 'n8n'"
size="medium"
icon="message-circle"
/>
<CredentialIcon
v-else
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[session.provider]"
@@ -5,18 +5,18 @@ import Modal from '@/components/Modal.vue';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
import { createEventBus } from '@n8n/utils/event-bus';
import { PROVIDER_CREDENTIAL_TYPE_MAP, type ChatHubProvider } from '@n8n/api-types';
import { type ChatHubLLMProvider, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
const props = defineProps<{
provider: ChatHubProvider;
provider: ChatHubLLMProvider;
initialValue: string | null;
}>();
const emit = defineEmits<{
select: [provider: ChatHubProvider, credentialId: string];
createNew: [provider: ChatHubProvider];
select: [provider: ChatHubLLMProvider, credentialId: string];
createNew: [provider: ChatHubLLMProvider];
}>();
const credentialsStore = useCredentialsStore();
@@ -3,11 +3,12 @@ import { computed, useTemplateRef } from 'vue';
import { N8nNavigationDropdown, N8nIcon, N8nButton, N8nText } from '@n8n/design-system';
import { type ComponentProps } from 'vue-component-type-helpers';
import {
type ChatHubConversationModel,
type ChatHubProvider,
chatHubProviderSchema,
type ChatModelsResponse,
PROVIDER_CREDENTIAL_TYPE_MAP,
type ChatHubConversationModel,
type ChatModelsResponse,
type ChatHubLLMProvider,
type ChatHubProvider,
} from '@n8n/api-types';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
@@ -21,63 +22,87 @@ const props = defineProps<{
const emit = defineEmits<{
change: [ChatHubConversationModel];
configure: [ChatHubProvider];
configure: [ChatHubLLMProvider];
}>();
const dropdownRef = useTemplateRef('dropdownRef');
const menu = computed(() =>
chatHubProviderSchema.options.map((provider) => {
const models = props.models?.[provider].models ?? [];
const error = props.models?.[provider].error;
chatHubProviderSchema.options
.filter((provider) => provider !== 'n8n') // Hide n8n provider for now
.map((provider: ChatHubProvider) => {
const models = props.models?.[provider].models ?? [];
const error = props.models?.[provider].error;
const modelOptions =
models.length > 0
? models.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((model) => ({
id: `${provider}::${model.name}`,
title: model.name,
disabled: false,
}))
: error
? [{ id: `${provider}::error`, disabled: true, title: error }]
: [];
const modelOptions =
models.length > 0
? models.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((model) => {
const identifier = model.provider === 'n8n' ? model.workflowId : model.model;
return {
id: provider,
title: providerDisplayNames[provider],
submenu: modelOptions.concat([
...(modelOptions.length > 0 ? [{ isDivider: true as const, id: 'divider' }] : []),
{
return {
id: `${provider}::${identifier}`,
title: model.name,
disabled: false,
};
})
: error
? [{ id: `${provider}::error`, value: null, disabled: true, title: error }]
: [];
const submenu = modelOptions.concat([
...(provider !== 'n8n' && modelOptions.length > 0
? [{ isDivider: true as const, id: 'divider' }]
: []),
]);
if (provider !== 'n8n') {
submenu.push({
id: `${provider}::configure`,
icon: 'settings',
title: 'Configure credentials...',
disabled: false,
},
]),
};
}),
});
}
return {
id: provider,
hidden: true,
title: providerDisplayNames[provider],
submenu,
};
}),
);
const selectedLabel = computed(() => {
if (!props.selectedModel) return 'Select model';
return props.selectedModel.model;
return props.selectedModel.name;
});
function onSelect(id: string) {
// Format is "provider::model"
const [provider, model] = id.split('::');
// Format is "provider::identifier", where identifier is either "configure", model name, or workflow ID for n8n
const [provider, identifier] = id.split('::');
const parsedProvider = chatHubProviderSchema.safeParse(provider).data;
if (!parsedProvider) {
return;
}
if (model === 'configure') {
if (identifier === 'configure' && parsedProvider !== 'n8n') {
emit('configure', parsedProvider);
return;
}
emit('change', { provider: parsedProvider, model, workflowId: null });
const model = parsedProvider === 'n8n' ? null : identifier;
const workflowId = parsedProvider === 'n8n' ? identifier : null;
const selected = props.models?.[parsedProvider].models.find((m) =>
m.provider === 'n8n' ? m.workflowId === workflowId : m.model === model,
);
if (!selected) {
return;
}
emit('change', selected);
}
onClickOutside(
@@ -95,7 +120,7 @@ defineExpose({
<template #item-icon="{ item }">
<CredentialIcon
v-if="item.id in PROVIDER_CREDENTIAL_TYPE_MAP"
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[item.id as ChatHubProvider]"
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[item.id as ChatHubLLMProvider]"
:size="16"
:class="$style.menuIcon"
/>
@@ -103,8 +128,10 @@ defineExpose({
<N8nButton :class="$style.dropdownButton" type="secondary" text>
<CredentialIcon
v-if="selectedModel"
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.provider]"
v-if="selectedModel && selectedModel.provider in PROVIDER_CREDENTIAL_TYPE_MAP"
:credential-type-name="
PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.provider as ChatHubLLMProvider]
"
:size="credentialsName ? 20 : 16"
:class="$style.icon"
/>
@@ -10,6 +10,7 @@ export const providerDisplayNames: Record<ChatHubProvider, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Google',
n8n: 'n8n',
};
export const MOBILE_MEDIA_QUERY = '(max-width: 768px)';
+1
View File
@@ -41,6 +41,7 @@ export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform';
export const FORM_NODE_TYPE = 'n8n-nodes-base.form';
export const FORM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.formTrigger';
export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger';
export const RESPOND_TO_CHAT_NODE_TYPE = '@n8n/n8n-nodes-langchain.chat';
export const WAIT_NODE_TYPE = 'n8n-nodes-base.wait';
export const RESPOND_TO_WEBHOOK_NODE_TYPE = 'n8n-nodes-base.respondToWebhook';
export const HTML_NODE_TYPE = 'n8n-nodes-base.html';