mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(core): Chatting with n8n agent workflows (no-changelog) (#21088)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-6
@@ -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"
|
||||
|
||||
+5
-1
@@ -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]"
|
||||
|
||||
+4
-4
@@ -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)';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user