feat(core): Chat hub implementation continued (no-changelog) (#20567)

Co-authored-by: Suguru Inoue <suguru@n8n.io>
This commit is contained in:
Jaakko Husso 2025-10-09 11:32:16 +03:00 committed by GitHub
parent 57e5ac536e
commit fc63dd7559
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1625 additions and 86 deletions

View File

@ -0,0 +1,42 @@
import { z } from 'zod';
/**
* Supported AI model providers
*/
export const chatHubProviderSchema = z.enum(['openai', 'anthropic', 'google']);
export type ChatHubProvider = z.infer<typeof chatHubProviderSchema>;
/**
* Map of providers to their credential types
*/
export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<ChatHubProvider, string> = {
openai: 'openAiApi',
anthropic: 'anthropicApi',
google: 'googlePalmApi',
};
/**
* Chat Hub conversation model configuration
*/
export const chatHubConversationModelSchema = z.object({
provider: chatHubProviderSchema,
model: z.string(),
});
export type ChatHubConversationModel = z.infer<typeof chatHubConversationModelSchema>;
/**
* Request schema for fetching available chat models
* Maps provider names to credential IDs (null if no credential available)
*/
export const chatModelsRequestSchema = z.object({
credentials: z.record(chatHubProviderSchema, z.string().nullable()),
});
export type ChatModelsRequest = z.infer<typeof chatModelsRequestSchema>;
/**
* Response type for fetching available chat models
*/
export type ChatModelsResponse = ChatHubConversationModel[];

View File

@ -6,6 +6,16 @@ export type * from './frontend-settings';
export type * from './user';
export type * from './api-keys';
export type * from './community-node-types';
export {
chatHubConversationModelSchema,
type ChatHubConversationModel,
chatHubProviderSchema,
type ChatHubProvider,
PROVIDER_CREDENTIAL_TYPE_MAP,
chatModelsRequestSchema,
type ChatModelsRequest,
type ChatModelsResponse,
} from './chat-hub';
export type { Collaborator } from './push/collaboration';
export type { HeartbeatMessage } from './push/heartbeat';

View File

@ -146,6 +146,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"mcpApiKey:rotate",
"mcpApiKey:*",
"chatHub:manage",
"chatHub:message",
"chatHub:*",
"*",
]

View File

@ -33,7 +33,7 @@ export const RESOURCES = {
role: ['manage'] as const,
mcp: ['manage'] as const,
mcpApiKey: ['create', 'rotate'] as const,
chatHub: ['manage'] as const,
chatHub: ['manage', 'message'] as const,
} as const;
export const API_KEY_RESOURCES = {

View File

@ -95,6 +95,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'mcpApiKey:create',
'mcpApiKey:rotate',
'chatHub:manage',
'chatHub:message',
];
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
@ -117,4 +118,5 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [
'dataTable:list',
'mcpApiKey:create',
'mcpApiKey:rotate',
'chatHub:message',
];

View File

@ -132,3 +132,5 @@ export const WsStatusCodes = {
} as const;
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
export const STREAM_SEPARATOR = '⧉⇋⇋➽⌑⧉§§\n';

View File

@ -1,3 +1,4 @@
import { FREE_AI_CREDITS_CREDENTIAL_NAME, STREAM_SEPARATOR } from '@/constants';
import type { CreateCredentialDto } from '@n8n/api-types';
import {
AiChatRequestDto,
@ -15,7 +16,6 @@ import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import { strict as assert } from 'node:assert';
import { WritableStream } from 'node:stream/web';
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ContentTooLargeError } from '@/errors/response-errors/content-too-large.error';
@ -75,7 +75,7 @@ export class AiController {
// Handle the stream
for await (const chunk of aiResponse) {
res.flush();
res.write(JSON.stringify(chunk) + '⧉⇋⇋➽⌑⧉§§\n');
res.write(JSON.stringify(chunk) + STREAM_SEPARATOR);
}
} catch (streamError) {
// If an error occurs during streaming, send it as part of the stream
@ -92,7 +92,7 @@ export class AiController {
},
],
};
res.write(JSON.stringify(errorChunk) + '⧉⇋⇋➽⌑⧉§§\n');
res.write(JSON.stringify(errorChunk) + STREAM_SEPARATOR);
} finally {
// Clean up event listener
res.off('close', handleClose);

View File

@ -1,80 +1,69 @@
import type { ChatModelsResponse } from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { RestController, Get, Post, Body } from '@n8n/decorators';
import { RestController, Post, Body, GlobalScope } from '@n8n/decorators';
import type { Response } from 'express';
import { strict as assert } from 'node:assert';
import { ChatHubService } from './chat-hub.service';
import { ChatMessageRequestDto } from './dto/chat-message-request.dto';
export type FlushableResponse = Response & { flush: () => void };
import { AskAiWithCredentialsRequestDto } from './dto/ask-ai-with-credentials-request.dto';
import { ChatModelsRequestDto } from './dto/chat-models-request.dto';
@RestController('/chat')
export class ChatHubController {
constructor(private readonly chatService: ChatHubService) {}
@Get('/agents/models/openai')
async getModels(_req: AuthenticatedRequest, res: FlushableResponse) {
const models = await this.chatService.getModels();
res.json(models);
@Post('/models')
async getModels(
req: AuthenticatedRequest,
_res: Response,
@Body payload: ChatModelsRequestDto,
): Promise<ChatModelsResponse> {
return await this.chatService.getModels(req.user.id, payload.credentials);
}
@Post('/agents/openai')
async ask(
@GlobalScope('chatHub:message')
@Post('/send')
async sendMessage(
req: AuthenticatedRequest,
res: FlushableResponse,
@Body payload: ChatMessageRequestDto,
res: Response,
@Body payload: AskAiWithCredentialsRequestDto,
) {
res.header('Content-type', 'application/json-lines; charset=utf-8');
res.header('Transfer-Encoding', 'chunked');
res.header('Connection', 'keep-alive');
res.header('Cache-Control', 'no-cache');
res.flushHeaders();
// TODO: Save human message to DB
const replyId = crypto.randomUUID();
try {
const abortController = new AbortController();
const { signal } = abortController;
await this.chatService.askN8n(res, req.user, {
...payload,
userId: req.user.id,
replyId,
});
} catch (executionError: unknown) {
assert(executionError instanceof Error);
const handleClose = () => abortController.abort();
res.on('close', handleClose);
const aiResponse = this.chatService.ask(payload, req.user, signal);
try {
// Handle the stream
for await (const chunk of aiResponse) {
res.flush();
res.write(JSON.stringify(chunk) + '⧉⇋⇋➽⌑⧉§§\n');
}
} catch (streamError) {
// If an error occurs during streaming, send it as part of the stream
// This prevents "Cannot set headers after they are sent" error
assert(streamError instanceof Error);
// Send error as proper error type now that frontend supports it
const errorChunk = {
messages: [
{
role: 'assistant',
type: 'error',
content: streamError.message,
},
],
};
res.write(JSON.stringify(errorChunk) + '⧉⇋⇋➽⌑⧉§§\n');
} finally {
// Clean up event listener
res.off('close', handleClose);
}
res.end();
} catch (e: unknown) {
// This catch block handles errors that occur before streaming starts
// Since headers haven't been sent yet, we can still send a proper error response
assert(e instanceof Error);
if (!res.headersSent) {
res.status(500).json({
code: 500,
message: e.message,
message: executionError.message,
});
} else {
// If headers were already sent dont't send a second error response
res.end();
res.write(
JSON.stringify({
type: 'error',
content: executionError.message,
id: replyId,
}) + '\n',
);
res.flush();
}
if (!res.writableEnded) res.end();
}
}
}

View File

@ -1,23 +1,442 @@
import {
PROVIDER_CREDENTIAL_TYPE_MAP,
type ChatHubProvider,
type ChatHubConversationModel,
type ChatModelsResponse,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import {
ExecutionRepository,
IExecutionResponse,
ProjectRepository,
SharedWorkflow,
SharedWorkflowRepository,
User,
WorkflowEntity,
WorkflowRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { type IUser } from 'n8n-workflow';
import type { Response } from 'express';
import {
OperationalError,
type IConnections,
type INode,
type INodeCredentialsDetails,
type ITaskData,
type IWorkflowBase,
type StartNodeData,
} from 'n8n-workflow';
import { v4 as uuidv4 } from 'uuid';
import { type ChatPayload } from './chat-hub.types';
import type { ChatPayloadWithCredentials } from './chat-hub.types';
import { CredentialsHelper } from '@/credentials-helper';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { getBase } from '@/workflow-execute-additional-data';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
@Service()
export class ChatHubService {
constructor() {}
constructor(
private readonly logger: Logger,
private readonly credentialsHelper: CredentialsHelper,
private readonly executionRepository: ExecutionRepository,
private readonly workflowExecutionService: WorkflowExecutionService,
private readonly workflowRepository: WorkflowRepository,
private readonly projectRepository: ProjectRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
) {}
async getModels() {
return await Promise.resolve(['gpt-3.5-turbo', 'gpt-4']);
async getModels(
userId: string,
credentialIds: Record<ChatHubProvider, string | null>,
): Promise<ChatModelsResponse> {
const models: ChatHubConversationModel[] = [];
const additionalData = await getBase({ userId });
for (const [providerKey, credentialId] of Object.entries(credentialIds)) {
if (!credentialId) {
continue;
}
// Type assertion is safe here because credentialIds type guarantees valid keys
const provider = providerKey as ChatHubProvider;
const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
try {
// Get the decrypted credential data
const nodeCredentials: INodeCredentialsDetails = {
id: credentialId,
name: credentialType,
};
const credentials = await this.credentialsHelper.getDecrypted(
additionalData,
nodeCredentials,
credentialType,
'internal',
undefined,
true,
);
// Extract API key from credentials based on provider
const apiKey = this.extractApiKey(provider, credentials);
if (!apiKey) {
continue;
}
// Fetch models dynamically from the provider
const providerModels = await this.fetchModelsForProvider(provider, apiKey);
models.push(...providerModels);
} catch (error) {
this.logger.debug(`Failed to get models for ${provider}: ${error}`);
}
}
return models;
}
async *ask(_payload: ChatPayload, _user: IUser, _abortSignal?: AbortSignal) {
yield* [
private async fetchModelsForProvider(
provider: ChatHubProvider,
apiKey: string,
): Promise<ChatHubConversationModel[]> {
switch (provider) {
case 'openai':
return await this.fetchOpenAiModels(apiKey);
case 'anthropic':
return await this.fetchAnthropicModels(apiKey);
case 'google':
return await this.fetchGoogleModels(apiKey);
}
}
private async fetchOpenAiModels(apiKey: string): Promise<ChatHubConversationModel[]> {
try {
const response = await fetch('https://api.openai.com/v1/models', {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch OpenAI models: ${response.statusText}`);
}
const data = await response.json();
const models: ChatHubConversationModel[] = [];
// Filter for chat models only (GPT models)
const chatModels = data.data.filter(
(model: { id: string }) =>
model.id.includes('gpt') && !model.id.includes('instruct') && !model.id.includes('audio'),
);
for (const model of chatModels) {
models.push({
provider: 'openai',
model: model.id,
});
}
return models;
} catch (error) {
this.logger.debug(`Failed to fetch OpenAI models: ${error}`);
return [];
}
}
private async fetchAnthropicModels(apiKey: string): Promise<ChatHubConversationModel[]> {
// Anthropic doesn't have a public models list endpoint, so we'll use a curated list
// but validate the API key first
try {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }],
}),
});
// If authentication fails, don't return models
if (response.status === 401 || response.status === 403) {
return [];
}
// Return known Anthropic models
return [
{
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
},
{
provider: 'anthropic',
model: 'claude-3-opus-20240229',
},
{
provider: 'anthropic',
model: 'claude-3-sonnet-20240229',
},
{
provider: 'anthropic',
model: 'claude-3-haiku-20240307',
},
];
} catch (error) {
this.logger.debug(`Failed to validate Anthropic API key: ${error}`);
return [];
}
}
private async fetchGoogleModels(apiKey: string): Promise<ChatHubConversationModel[]> {
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`,
{
method: 'GET',
},
);
if (!response.ok) {
throw new Error(`Failed to fetch Google models: ${response.statusText}`);
}
const data = await response.json();
const models: ChatHubConversationModel[] = [];
// Filter for Gemini chat models
const chatModels = data.models?.filter(
(model: { name: string; supportedGenerationMethods?: string[] }) =>
model.name.includes('gemini') &&
model.supportedGenerationMethods?.includes('generateContent'),
);
for (const model of chatModels || []) {
// Extract model ID from the full name (e.g., "models/gemini-1.5-pro" -> "gemini-1.5-pro")
const modelId = model.name.split('/').pop();
models.push({
provider: 'google',
model: modelId,
});
}
return models;
} catch (error) {
this.logger.debug(`Failed to fetch Google models: ${error}`);
return [];
}
}
private extractApiKey(provider: ChatHubProvider, credentials: unknown): string | undefined {
if (typeof credentials !== 'object' || credentials === null) {
return undefined;
}
const creds = credentials as Record<string, unknown>;
switch (provider) {
case 'openai':
case 'anthropic':
case 'google':
// All providers use 'apiKey' field
return typeof creds.apiKey === 'string' ? creds.apiKey : undefined;
}
}
private async createChatWorkflow(
user: User,
sessionId: string,
nodes: INode[],
connections: IConnections,
) {
const { manager } = this.projectRepository;
const existing = await this.workflowRepository.findOneBy({ id: sessionId });
if (existing) {
return existing;
}
return await manager.transaction(async (trx) => {
const project = await this.projectRepository.getPersonalProjectForUser(user.id, trx);
if (!project) {
throw new NotFoundError('Could not find a personal project for this user');
}
const newWorkflow = new WorkflowEntity();
newWorkflow.versionId = uuidv4();
newWorkflow.id = sessionId;
newWorkflow.name = `Chat ${sessionId}`;
newWorkflow.active = false;
newWorkflow.nodes = nodes;
newWorkflow.connections = connections;
const workflow = await trx.save<WorkflowEntity>(newWorkflow);
await trx.save<SharedWorkflow>(
this.sharedWorkflowRepository.create({
role: 'workflow:owner',
projectId: project.id,
workflow,
}),
);
return workflow;
});
}
private getMessage(execution: IExecutionResponse): string | undefined {
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
if (typeof lastNodeExecuted !== 'string') return undefined;
const runIndex = execution.data.resultData.runData[lastNodeExecuted].length - 1;
const mainOutputs = execution.data.resultData.runData[lastNodeExecuted][runIndex]?.data?.main;
// Check all main output branches for a message
if (mainOutputs && Array.isArray(mainOutputs)) {
for (const branch of mainOutputs) {
if (branch && Array.isArray(branch) && branch.length > 0 && branch[0].json?.output) {
return branch[0].json.output as string;
}
}
}
return undefined;
}
async askN8n(res: Response, user: User, payload: ChatPayloadWithCredentials) {
/* eslint-disable @typescript-eslint/naming-convention */
const nodes: INode[] = [
{
role: 'assistant',
type: 'message',
message: 'hello world',
parameters: {
public: true,
mode: 'webhook',
options: { responseMode: 'streaming' },
},
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.3,
position: [0, 0],
id: uuidv4(),
name: 'When chat message received',
webhookId: uuidv4(),
},
{
parameters: {
options: {
enableStreaming: true,
},
},
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [200, 0],
id: uuidv4(),
name: 'AI Agent',
},
{
parameters: {
model: { __rl: true, mode: 'list', value: payload.model },
options: {},
},
type: payload.provider,
typeVersion: 1.2,
position: [80, 200],
id: uuidv4(),
name: 'Chat Model',
credentials: payload.credentials,
},
];
const connections: IConnections = {
'When chat message received': {
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]],
},
'Chat Model': {
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]],
},
};
const workflow = await this.createChatWorkflow(user, payload.sessionId, nodes, connections);
const workflowData: IWorkflowBase = {
...workflow,
nodes,
connections,
versionId: uuidv4(),
};
/* eslint-enable @typescript-eslint/naming-convention */
const startNodes: StartNodeData[] = [{ name: 'AI Agent', sourceData: null }];
const triggerToStartFrom: {
name: string;
data?: ITaskData;
} = {
name: 'When chat message received',
data: {
startTime: Date.now(),
executionTime: 0,
executionIndex: 0,
executionStatus: 'success',
data: {
main: [
[
{
json: {
sessionId: payload.sessionId,
action: 'sendMessage',
chatInput: payload.message,
},
},
],
],
},
source: [null],
},
};
this.logger.debug(`Starting execution of workflow "${workflow.name}" with ID ${workflow.id}`);
const { executionId } = await this.workflowExecutionService.executeManually(
{
workflowData,
startNodes,
triggerToStartFrom,
},
user,
undefined,
true,
res,
);
if (!executionId) {
throw new OperationalError('There was a problem starting the chat execution.');
}
// TODO: The execution finishes after a while, how do we store the full AI response on the database?
// Is there a better way to listen for the execution to finish?
const onClose = async () => {
this.logger.debug(`Connection closed by client, execution ID: ${executionId}`);
// TODO: we could maybe stop executions here if user disconnected early?
// if (execution && ['running', 'waiting'].includes(execution.status)) {
// await this.executionService.stop(executionId, [workflow.id]);
// }
const execution = await this.executionRepository.findWithUnflattenedData(executionId, [
workflow.id,
]);
// Persist the assistant message to the database
if (execution?.data?.resultData) {
// resultData is only available if the execution finished
const message = this.getMessage(execution);
this.logger.debug(`Assistant: ${message} (${payload.replyId})`);
}
};
res.on('close', onClose);
res.on('error', onClose);
}
}

View File

@ -1,4 +1,12 @@
export interface ChatPayload {
import type { INodeCredentials } from 'n8n-workflow';
export interface ChatPayloadWithCredentials {
userId: string;
message: string;
messageId: string;
sessionId: string;
replyId: string;
provider: '@n8n/n8n-nodes-langchain.lmChatOpenAi';
model: string;
credentials: INodeCredentials;
}

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export const NodeCredentialsDetailsSchema = z.object({
id: z.string(),
name: z.string(),
});
export const SupportedNodeTypesSchema = z.enum(['@n8n/n8n-nodes-langchain.lmChatOpenAi']);
export class AskAiWithCredentialsRequestDto extends Z.class({
messageId: z.string().uuid(),
sessionId: z.string().uuid(),
message: z.string(),
provider: SupportedNodeTypesSchema,
model: z.string(),
credentials: z.record(NodeCredentialsDetailsSchema),
}) {}

View File

@ -0,0 +1,4 @@
import { chatModelsRequestSchema } from '@n8n/api-types';
import { Z } from 'zod-class';
export class ChatModelsRequestDto extends Z.class(chatModelsRequestSchema.shape) {}

View File

@ -3,6 +3,7 @@ import { GlobalConfig } from '@n8n/config';
import type { Project, User, CreateExecutionPayload } from '@n8n/db';
import { ExecutionRepository, WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Response } from 'express';
import { ErrorReporter } from 'n8n-core';
import type {
IDeferredPromise,
@ -111,6 +112,8 @@ export class WorkflowExecutionService {
}: WorkflowRequest.ManualRunPayload,
user: User,
pushRef?: string,
streamingEnabled?: boolean,
httpResponse?: Response,
) {
const pinData = workflowData.pinData;
let pinnedTrigger = this.selectPinnedActivatorStarter(
@ -185,6 +188,8 @@ export class WorkflowExecutionService {
dirtyNodeNames,
triggerToStartFrom,
agentRequest,
streamingEnabled,
httpResponse,
};
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];

View File

@ -145,6 +145,7 @@ import IconLucideMaximize2 from '~icons/lucide/maximize-2';
import IconLucideMenu from '~icons/lucide/menu';
import IconLucideMessageCircle from '~icons/lucide/message-circle';
import IconLucideMessagesSquare from '~icons/lucide/messages-square';
import IconLucideMic from '~icons/lucide/mic';
import IconLucideMilestone from '~icons/lucide/milestone';
import IconLucideMinimize2 from '~icons/lucide/minimize-2';
import IconLucideMousePointer from '~icons/lucide/mouse-pointer';
@ -153,6 +154,7 @@ import IconLucidePackageOpen from '~icons/lucide/package-open';
import IconLucidePalette from '~icons/lucide/palette';
import IconLucidePanelLeft from '~icons/lucide/panel-left';
import IconLucidePanelRight from '~icons/lucide/panel-right';
import IconLucidePaperclip from '~icons/lucide/paperclip';
import IconLucidePause from '~icons/lucide/pause';
import IconLucidePen from '~icons/lucide/pen';
import IconLucidePencil from '~icons/lucide/pencil';
@ -573,6 +575,7 @@ export const updatedIconSet = {
menu: IconLucideMenu,
'message-circle': IconLucideMessageCircle,
'messages-square': IconLucideMessagesSquare,
mic: IconLucideMic,
milestone: IconLucideMilestone,
'mouse-pointer': IconLucideMousePointer,
network: IconLucideNetwork,
@ -580,6 +583,7 @@ export const updatedIconSet = {
palette: IconLucidePalette,
'panel-left': IconLucidePanelLeft,
'panel-right': IconLucidePanelRight,
paperclip: IconLucidePaperclip,
pause: IconLucidePause,
pen: IconLucidePen,
pencil: IconLucidePencil,

View File

@ -14,18 +14,19 @@ type BaseItem = {
disabled?: boolean;
icon?: IconName | { type: 'icon'; value: IconName } | { type: 'emoji'; value: string };
route?: RouteLocationRaw;
isDivider?: false;
};
type Item = BaseItem & {
submenu?: BaseItem[];
};
type Divider = { isDivider: true; id: string };
type Item = BaseItem & { submenu?: Array<BaseItem | Divider> };
defineOptions({
name: 'N8nNavigationDropdown',
});
defineProps<{
menu: Item[];
menu: Array<Item | Divider>;
disabled?: boolean;
teleport?: boolean;
}>();
@ -84,7 +85,8 @@ defineExpose({
</template>
<template v-for="item in menu" :key="item.id">
<template v-if="item.submenu">
<hr v-if="item.isDivider" />
<template v-else-if="item.submenu">
<ElSubMenu
:popper-class="$style.nestedSubmenu"
:index="item.id"
@ -93,7 +95,8 @@ defineExpose({
>
<template #title>{{ item.title }}</template>
<template v-for="subitem in item.submenu" :key="subitem.id">
<ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
<hr v-if="subitem.isDivider" />
<ConditionalRouterLink v-else :to="(!subitem.disabled && subitem.route) || undefined">
<ElMenuItem
data-test-id="navigation-submenu-item"
:index="subitem.id"
@ -159,6 +162,12 @@ defineExpose({
}
}
}
& hr {
border-top: none;
border-bottom: var(--border-base);
margin-block: var(--spacing-4xs);
}
}
.nestedSubmenu {

View File

@ -1,13 +1,13 @@
import { ResponseError, STREAM_SEPERATOR, streamRequest } from './utils';
import { ResponseError, STREAM_SEPARATOR, streamRequest } from './utils';
describe('streamRequest', () => {
it('should stream data from the API endpoint', async () => {
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPERATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 2 })}${STREAM_SEPERATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 3 })}${STREAM_SEPERATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPARATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 2 })}${STREAM_SEPARATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 3 })}${STREAM_SEPARATOR}`));
controller.close();
},
});
@ -107,11 +107,11 @@ describe('streamRequest', () => {
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPERATOR}{"chunk": `),
encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPARATOR}{"chunk": `),
);
controller.enqueue(encoder.encode(`2}${STREAM_SEPERATOR}{"ch`));
controller.enqueue(encoder.encode(`2}${STREAM_SEPARATOR}{"ch`));
controller.enqueue(encoder.encode('unk":'));
controller.enqueue(encoder.encode(`3}${STREAM_SEPERATOR}`));
controller.enqueue(encoder.encode(`3}${STREAM_SEPARATOR}`));
controller.close();
},
});

View File

@ -17,7 +17,7 @@ const getBrowserId = () => {
};
export const NO_NETWORK_ERROR_CODE = 999;
export const STREAM_SEPERATOR = '⧉⇋⇋➽⌑⧉§§\n';
export const STREAM_SEPARATOR = '⧉⇋⇋➽⌑⧉§§\n';
export class MfaRequiredError extends ApplicationError {
constructor() {
@ -218,7 +218,7 @@ export async function streamRequest<T extends object>(
onChunk?: (chunk: T) => void,
onDone?: () => void,
onError?: (e: Error) => void,
separator = STREAM_SEPERATOR,
separator = STREAM_SEPARATOR,
abortSignal?: AbortSignal,
): Promise<void> {
const headers: Record<string, string> = {

View File

@ -28,6 +28,7 @@ import {
VIEWS,
WHATS_NEW_MODAL_KEY,
} from '@/constants';
import { CHAT_VIEW } from '@/features/chatHub/constants';
import { hasPermission } from '@/utils/rbac/permissions';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useRootStore } from '@n8n/stores/useRootStore';
@ -199,6 +200,16 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
settingsStore.isModuleActive('insights') &&
hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
},
{
id: 'chat',
icon: 'bot',
label: 'Chat',
position: 'bottom',
route: { to: { name: CHAT_VIEW } },
available:
settingsStore.isChatFeatureEnabled &&
hasPermission(['rbac'], { rbac: { scope: 'chatHub:message' } }),
},
{
id: 'help',
icon: 'circle-help',

View File

@ -506,6 +506,7 @@ export const LOCAL_STORAGE_FOCUS_PANEL = 'N8N_FOCUS_PANEL';
export const LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS =
'N8N_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS';
export const LOCAL_STORAGE_RUN_DATA_WORKER = 'N8N_RUN_DATA_WORKER';
export const LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL = 'N8N_CHAT_HUB_SELECTED_MODEL';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const COMMUNITY_PLUS_DOCS_URL =

View File

@ -0,0 +1,605 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import hljs from 'highlight.js/lib/core';
import { N8nHeading, N8nIcon, N8nText, N8nScrollArea } from '@n8n/design-system';
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
import ModelSelector from './components/ModelSelector.vue';
import { useChatStore } from './chat.store';
import { useUsersStore } from '@/stores/users.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useUIStore } from '@/stores/ui.store';
import type { ChatMessage, Suggestion } from './chat.types';
import {
chatHubConversationModelSchema,
type ChatHubProvider,
PROVIDER_CREDENTIAL_TYPE_MAP,
type ChatHubConversationModel,
chatHubProviderSchema,
} from '@n8n/api-types';
import VueMarkdown from 'vue-markdown-render';
import markdownLink from 'markdown-it-link-attributes';
import type MarkdownIt from 'markdown-it';
import { useLocalStorage } from '@vueuse/core';
import { LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL } from '@/constants';
import { SUGGESTIONS } from '@/features/chatHub/constants';
import { isSameModel } from '@/features/chatHub/chat.utils';
const chatStore = useChatStore();
const userStore = useUsersStore();
const credentialsStore = useCredentialsStore();
const uiStore = useUIStore();
onMounted(async () => {
await Promise.all([
credentialsStore.fetchCredentialTypes(false),
credentialsStore.fetchAllCredentials(),
]);
const models = await chatStore.fetchChatModels(
Object.fromEntries(
chatHubProviderSchema.options.map((provider) => [
provider,
credentialsStore.getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[provider])[0]?.id ??
null,
]),
) as Record<ChatHubProvider, string | null>,
);
const selected = selectedModel.value;
if (selected === null || models.every((model) => !isSameModel(model, selected))) {
selectedModel.value = models[0] ?? null;
}
});
const message = ref('');
const sessionId = ref(uuidv4());
const messagesRef = ref<HTMLDivElement | null>(null);
const scrollAreaRef = ref<InstanceType<typeof N8nScrollArea>>();
const selectedModel = useLocalStorage<ChatHubConversationModel | null>(
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL,
null,
{
writeDefaults: false,
shallow: true,
serializer: {
read: (value) => {
try {
const result = chatHubConversationModelSchema.parse(JSON.parse(value));
return result;
} catch (error) {
return null;
}
},
write: (value) => JSON.stringify(value),
},
},
);
const hasMessages = computed(() => chatStore.chatMessages.length > 0);
const inputPlaceholder = computed(() => {
if (!selectedModel.value) {
return 'Select a model';
}
const modelName = selectedModel.value.model;
return `Message ${modelName}`;
});
const scrollOnNewMessage = ref(true);
function getScrollViewport(): HTMLElement | null {
const root = scrollAreaRef.value?.$el as HTMLElement | undefined;
return root?.querySelector('[data-reka-scroll-area-viewport]') as HTMLElement | null;
}
function scrollToBottom() {
const viewport = getScrollViewport();
if (viewport && messagesRef.value) {
viewport.scrollTo({
top: messagesRef.value.scrollHeight,
behavior: 'smooth',
});
}
}
watch(
() => chatStore.chatMessages,
async (messages) => {
// Check if the last message is user and scroll to bottom of the chat
if (scrollOnNewMessage.value && messages.length > 0) {
// Wait for DOM updates before scrolling
await nextTick();
// Check if viewport is available after nextTick
if (getScrollViewport()) {
scrollToBottom();
}
}
},
{ immediate: true, deep: true },
);
function onModelChange(selection: ChatHubConversationModel) {
selectedModel.value = selection;
}
function onConfigure(provider: ChatHubProvider) {
uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]);
}
function onSubmit() {
if (!message.value.trim() || chatStore.isResponding || !selectedModel.value) {
return;
}
chatStore.askAI(message.value, sessionId.value, selectedModel.value);
message.value = '';
}
function onSuggestionClick(s: Suggestion) {
message.value = `${s.title} ${s.subtitle}`;
}
function onAttach() {}
function onMic() {}
function messageText(msg: ChatMessage) {
return msg.type === 'message' ? msg.text : `**Error:** ${msg.content}`;
}
const markdownOptions = {
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch {}
}
return ''; // use external default escaping
},
};
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
vueMarkdownItInstance.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
};
</script>
<template>
<PageViewLayout>
<ModelSelector
:class="$style.modelSelector"
:models="chatStore.models"
:selected-model="selectedModel"
:disabled="chatStore.isResponding"
@change="onModelChange"
@configure="onConfigure"
/>
<div
:class="{
[$style.content]: true,
[$style.centered]: !hasMessages,
}"
>
<section
:class="{
[$style.section]: true,
[$style.fullHeight]: hasMessages,
}"
>
<div v-if="!hasMessages" :class="$style.header">
<N8nHeading tag="h2" bold size="xlarge">
{{
`Good morning, ${userStore.currentUser?.firstName ?? userStore.currentUser?.fullName ?? 'User'}!`
}}
</N8nHeading>
</div>
<div v-if="!hasMessages" :class="$style.suggestions">
<button
v-for="s in SUGGESTIONS"
:key="s.title"
type="button"
:class="$style.card"
@click="onSuggestionClick(s)"
>
<div :class="$style.cardIcon" aria-hidden="true">
<N8nText size="xlarge">{{ s.icon }}</N8nText>
</div>
<div :class="$style.cardText">
<N8nText bold color="text-dark">{{ s.title }}</N8nText>
<N8nText color="text-base">{{ s.subtitle }}</N8nText>
</div>
</button>
</div>
<!-- Chat thread -->
<template v-else>
<div :class="$style.threadContainer">
<N8nScrollArea
ref="scrollAreaRef"
type="hover"
:enable-vertical-scroll="true"
:enable-horizontal-scroll="false"
:class="$style.threadWrap"
>
<div ref="messagesRef" :class="$style.thread" role="log" aria-live="polite">
<div
v-for="m in chatStore.chatMessages"
:key="m.id"
:class="[
$style.message,
m.role === 'user' ? $style.user : $style.assistant,
m.type === 'error' && $style.error,
]"
>
<div :class="$style.avatar">
<N8nIcon
:icon="m.role === 'user' ? 'user' : 'sparkles'"
width="20"
height="20"
/>
</div>
<div
:class="{
[$style.chatMessage]: true,
[$style.chatMessageFromUser]: m.role === 'user',
[$style.chatMessageFromAssistant]: m.role === 'assistant',
}"
>
<VueMarkdown
:class="$style.chatMessageMarkdown"
:source="messageText(m)"
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
/>
</div>
</div>
<!-- Typing indicator while streaming -->
<div v-if="chatStore.isResponding" :class="[$style.message, $style.assistant]">
<div :class="$style.avatar">
<N8nIcon icon="sparkles" width="20" height="20" />
</div>
<div :class="$style.bubble">
<span :class="$style.typing"><i></i><i></i><i></i></span>
</div>
</div>
</div>
</N8nScrollArea>
</div>
</template>
<!-- Prompt -->
<form :class="$style.prompt" @submit.prevent="onSubmit">
<div :class="$style.inputWrap">
<input
v-model="message"
:class="$style.input"
type="text"
:placeholder="inputPlaceholder"
autocomplete="off"
:disabled="chatStore.isResponding"
/>
<div :class="$style.actions">
<button
:class="$style.iconBtn"
type="button"
title="Attach"
:disabled="chatStore.isResponding"
@click="onAttach"
>
<N8nIcon icon="paperclip" width="20" height="20" />
</button>
<button
:class="$style.iconBtn"
type="button"
title="Voice"
:disabled="chatStore.isResponding"
@click="onMic"
>
<N8nIcon icon="mic" width="20" height="20" />
</button>
<button
:class="$style.sendBtn"
type="submit"
:disabled="chatStore.isResponding || !message.trim()"
>
<span v-if="!chatStore.isResponding">Send</span>
<span v-else></span>
</button>
</div>
</div>
<N8nText :class="$style.disclaimer" color="text-light" size="small">
AI may make mistakes. Check important info.
<br />
{{ sessionId }}
</N8nText>
</form>
</section>
</div>
</PageViewLayout>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
padding-bottom: var(--spacing-l);
height: 100%;
min-height: 0;
}
.centered {
justify-content: center;
}
.section {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-l);
}
.fullHeight {
flex: 1;
min-height: 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Suggestions */
.suggestions {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: var(--spacing-m);
width: min(960px, 90%);
margin-top: var(--spacing-m);
}
@media (max-width: 800px) {
.suggestions {
grid-template-columns: 1fr;
}
}
.card {
display: flex;
align-items: flex-start;
gap: var(--spacing-s);
padding: var(--spacing-m);
border: 1px solid var(--color-foreground-base);
background: var(--color-background-base);
border-radius: var(--border-radius-large);
text-align: left;
cursor: pointer;
transition:
transform 0.06s ease,
background 0.06s ease,
border-color 0.06s ease;
}
.card:hover {
border-color: var(--color-primary);
background: rgba(124, 58, 237, 0.04);
}
.cardIcon {
height: 100%;
display: flex;
align-items: center;
}
.cardText {
display: grid;
gap: 2px;
}
.threadWrap {
flex: 1;
height: 100%;
min-height: 0;
}
.threadContainer {
width: min(960px, 90%);
flex: 1;
min-height: 0;
display: flex;
}
.thread {
padding: var(--spacing-m);
background: var(--color-background-light);
}
.message {
display: grid;
grid-template-columns: 28px 1fr;
gap: var(--spacing-s);
margin-bottom: var(--spacing-m);
}
.avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-background-xlight);
color: var(--color-text-light);
}
.chatMessage {
display: block;
position: relative;
max-width: fit-content;
padding: var(--spacing-m);
border-radius: var(--border-radius-large);
&.chatMessageFromAssistant {
background-color: var(--color-background-base);
}
&.chatMessageFromUser {
background-color: var(--color-background-medium);
}
> .chatMessageMarkdown {
display: block;
box-sizing: border-box;
font-size: inherit;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
p {
margin: var(--spacing-xs) 0;
}
pre {
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre-wrap;
box-sizing: border-box;
padding: var(--chat--spacing);
background: var(--chat--message--pre--background);
border-radius: var(--chat--border-radius);
}
}
}
/* Typing indicator */
.typing {
display: inline-flex;
gap: 6px;
}
.typing i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
animation: blink 1.2s infinite;
}
.typing i:nth-child(2) {
animation-delay: 0.2s;
}
.typing i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.35;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-2px);
}
}
/* Prompt */
.prompt {
display: grid;
place-items: center;
width: 100%;
margin-top: var(--spacing-m);
}
.inputWrap {
position: relative;
display: flex;
align-items: center;
width: min(720px, 50vw);
min-width: 320px;
& input:disabled {
cursor: not-allowed;
}
}
.input {
flex: 1;
font: inherit;
padding: 14px 112px 14px 14px;
border: 1px solid var(--color-foreground-base);
background: var(--color-background-light);
color: var(--color-text-dark);
border-radius: 16px;
outline: none;
}
.input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
}
/* Right-side actions */
.actions {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 6px;
}
.iconBtn {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--color-text-light);
cursor: pointer;
}
.iconBtn:hover {
background: rgba(0, 0, 0, 0.04);
color: var(--color-text-dark);
}
.sendBtn {
height: 32px;
padding: 0 10px;
border-radius: 10px;
border: none;
background: var(--color-primary);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.sendBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.disclaimer {
margin-top: var(--spacing-xs);
color: var(--color-text-lighter);
text-align: center;
}
.modelSelector {
position: absolute;
top: var(--spacing-s);
left: var(--spacing-s);
z-index: 100;
}
</style>

View File

@ -0,0 +1,38 @@
import { makeRestApiRequest, streamRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type { ChatModelsRequest, ChatModelsResponse } from '@n8n/api-types';
import type { StructuredChunk } from './chat.types';
import type { INodeCredentials } from 'n8n-workflow';
export const fetchChatModelsApi = async (
context: IRestApiContext,
payload: ChatModelsRequest,
): Promise<ChatModelsResponse> => {
const apiEndpoint = '/chat/models';
return await makeRestApiRequest<ChatModelsResponse>(context, 'POST', apiEndpoint, payload);
};
export const sendText = (
ctx: IRestApiContext,
payload: {
message: string;
provider: string;
model: string;
messageId: string;
sessionId: string;
credentials: INodeCredentials;
},
onMessageUpdated: (data: StructuredChunk) => void,
onDone: () => void,
onError: (e: Error) => void,
): void => {
void streamRequest<StructuredChunk>(
ctx,
'/chat/send',
payload,
onMessageUpdated,
onDone,
onError,
'\n',
);
};

View File

@ -0,0 +1,141 @@
import { defineStore } from 'pinia';
import { CHAT_STORE } from './constants';
import { computed, ref } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { fetchChatModelsApi, sendText } from './chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { ChatHubProvider } from '@n8n/api-types';
import type { StructuredChunk, ChatMessage, ChatHubConversationModel } from './chat.types';
export const useChatStore = defineStore(CHAT_STORE, () => {
const rootStore = useRootStore();
const models = ref<ChatHubConversationModel[]>([]);
const loadingModels = ref(false);
const isResponding = ref(false);
const chatMessages = ref<ChatMessage[]>([]);
const assistantMessages = computed(() =>
chatMessages.value.filter((msg) => msg.role === 'assistant'),
);
const usersMessages = computed(() => chatMessages.value.filter((msg) => msg.role === 'user'));
function setModels(newModels: ChatHubConversationModel[]) {
models.value = newModels;
}
async function fetchChatModels(credentialMap: Record<ChatHubProvider, string | null>) {
loadingModels.value = true;
models.value = await fetchChatModelsApi(rootStore.restApiContext, {
credentials: credentialMap,
});
loadingModels.value = false;
return models.value;
}
function addUserMessage(content: string, id: string) {
chatMessages.value.push({
id,
role: 'user',
type: 'message',
text: content,
});
}
function addAiMessage(content: string, id: string) {
chatMessages.value.push({
id,
role: 'assistant',
type: 'message',
text: content,
});
}
function appendMessage(content: string, id: string) {
const existingMessage = chatMessages.value.find((m) => m.id === id);
if (existingMessage && existingMessage.type === 'message') {
existingMessage.text += content;
return;
}
}
function onBeginMessage(messageId: string, nodeId: string, runIndex?: number) {
isResponding.value = true;
addAiMessage('', `${messageId}-${nodeId}-${runIndex ?? 0}`);
}
function onChunk(messageId: string, chunk: string, nodeId?: string, runIndex?: number) {
appendMessage(chunk, `${messageId}-${nodeId}-${runIndex ?? 0}`);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function onEndMessage(_messageId: string, _nodeId: string, _runIndex?: number) {
isResponding.value = false;
}
function onStreamMessage(message: StructuredChunk, messageId: string) {
const nodeId = message.metadata?.nodeId || 'unknown';
const runIndex = message.metadata?.runIndex;
switch (message.type) {
case 'begin':
onBeginMessage(messageId, nodeId, runIndex);
break;
case 'item':
onChunk(messageId, message.content ?? '', nodeId, runIndex);
break;
case 'end':
onEndMessage(messageId, nodeId, runIndex);
break;
case 'error':
onChunk(messageId, `Error: ${message.content ?? 'Unknown error'}`, nodeId, runIndex);
onEndMessage(messageId, nodeId, runIndex);
break;
}
// addAssistantMessages(response.messages);
}
function onStreamDone() {
isResponding.value = false;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function onStreamError(_e: Error) {
isResponding.value = false;
}
const askAI = (message: string, sessionId: string, model: ChatHubConversationModel) => {
const messageId = uuidv4();
addUserMessage(message, messageId);
sendText(
rootStore.restApiContext,
{
model: model.model,
provider: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
messageId,
sessionId,
message,
credentials: {
openAiApi: { id: 'Jtx6ADZkQZARdxae', name: 'OpenAi account' },
},
},
(chunk: StructuredChunk) => onStreamMessage(chunk, messageId),
onStreamDone,
onStreamError,
);
};
return {
models,
loadingModels,
setModels,
fetchChatModels,
askAI,
isResponding,
chatMessages,
assistantMessages,
usersMessages,
addUserMessage,
};
});

View File

@ -0,0 +1,56 @@
export type { ChatHubConversationModel } from '@n8n/api-types';
export interface UserMessage {
id: string;
role: 'user';
type: 'message';
text: string;
}
export interface AssistantMessage {
id: string;
role: 'assistant';
type: 'message';
text: string;
}
export interface ErrorMessage {
id: string;
role: 'assistant';
type: 'error';
content: string;
}
export type StreamChunk = AssistantMessage | ErrorMessage;
export type ChatMessage = UserMessage | AssistantMessage | ErrorMessage;
export interface StreamOutput {
messages: StreamChunk[];
}
export type Suggestion = {
title: string;
subtitle: string;
icon?: string;
};
// From @n8n/chat
export type ChunkType = 'begin' | 'item' | 'end' | 'error';
export interface StructuredChunk {
type: ChunkType;
content?: string;
metadata: {
nodeId: string;
nodeName: string;
timestamp: number;
runIndex: number;
itemIndex: number;
};
}
export interface NodeStreamingState {
nodeId: string;
chunks: string[];
isActive: boolean;
startTime: number;
}

View File

@ -0,0 +1,8 @@
import type { ChatHubConversationModel } from '@n8n/api-types';
export function isSameModel(
one: ChatHubConversationModel,
another: ChatHubConversationModel,
): boolean {
return one.model === another.model && one.provider === another.provider;
}

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nNavigationDropdown, N8nIcon, N8nButton } from '@n8n/design-system';
import type { ChatHubConversationModel } from '../chat.types';
import { type ComponentProps } from 'vue-component-type-helpers';
import { type ChatHubProvider, chatHubProviderSchema } from '@n8n/api-types';
const props = defineProps<{
disabled?: boolean;
models: ChatHubConversationModel[];
selectedModel: ChatHubConversationModel | null;
}>();
const emit = defineEmits<{
change: [ChatHubConversationModel];
configure: [ChatHubProvider];
}>();
const providerDisplayNames: Record<ChatHubProvider, string> = {
openai: 'Open AI',
anthropic: 'Anthropic',
google: 'Google',
};
const menu = computed(() =>
chatHubProviderSchema.options.map((provider) => {
const modelOptions = props.models
.filter((model) => model.provider === provider)
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((model) => ({
id: `${model.provider}::${model.model}`,
title: model.model,
disabled: false,
}));
return {
id: provider,
title: providerDisplayNames[provider],
submenu: modelOptions.concat([
...(modelOptions.length > 0 ? [{ isDivider: true as const, id: 'divider' }] : []),
{
id: `${provider}::configure`,
title: 'Configure credentials...',
disabled: false,
},
]),
};
}),
);
const selectedLabel = computed(() => {
if (!props.selectedModel) return 'Select model';
return props.selectedModel.model;
});
function onSelect(id: string) {
// Format is "provider::model"
const [provider, model] = id.split('::');
const parsedProvider = chatHubProviderSchema.safeParse(provider).data;
if (model === 'configure' && parsedProvider) {
emit('configure', parsedProvider);
return;
}
const selectedModel = props.models.find((m) => m.provider === provider && m.model === model);
if (selectedModel) {
emit('change', selectedModel);
}
}
</script>
<template>
<N8nNavigationDropdown
:menu="menu"
:disabled="disabled || models.length === 0"
@select="onSelect"
>
<N8nButton :class="$style.dropdownButton" type="secondary">
<span>{{ selectedLabel }}</span>
<N8nIcon icon="chevron-down" size="small" />
</N8nButton>
</N8nNavigationDropdown>
</template>
<style lang="scss" module>
.dropdownButton {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
}
</style>

View File

@ -0,0 +1,29 @@
import type { Suggestion } from './chat.types';
// Route and view identifiers
export const CHAT_VIEW = 'chat';
export const CHAT_STORE = 'chatStore';
export const SUGGESTIONS: Suggestion[] = [
{
title: 'Brainstorm ideas',
subtitle: 'for a product launch or campaign',
icon: '💡',
},
{
title: 'Explain a concept',
subtitle: "like Docker as if I'm 12",
icon: '📘',
},
{
title: 'Summarize text',
subtitle: 'paste content and get a TL;DR',
icon: '📝',
},
{
title: 'Draft an email',
subtitle: 'polite follow-up about a bug',
icon: '✉️',
},
];

View File

@ -0,0 +1,36 @@
import { type FrontendModuleDescription } from '@/moduleInitializer/module.types';
import { CHAT_VIEW } from './constants';
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const ChatView = async () => await import('@/features/chatHub/ChatView.vue');
export const ChatModule: FrontendModuleDescription = {
id: 'chat-hub',
name: 'Chat',
description: 'Interact with various LLM models or your n8n AI agents.',
icon: 'chat',
modals: [],
routes: [
{
name: CHAT_VIEW,
path: '/ask',
components: {
default: ChatView,
sidebar: MainSidebar,
},
meta: {
middleware: ['authenticated', 'custom'],
},
},
],
projectTabs: {
overview: [],
project: [],
},
resources: [
{
key: 'chat',
displayName: 'Chat',
},
],
};

View File

@ -6,13 +6,19 @@ import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { InsightsModule } from '../features/insights/module.descriptor';
import { MCPModule } from '../features/mcpAccess/module.descriptor';
import { ChatModule } from '@/features/chatHub/module.descriptor';
import type { FrontendModuleDescription } from '@/moduleInitializer/module.types';
import * as modalRegistry from '@/moduleInitializer/modalRegistry';
/**
* Hard-coding modules list until we have a dynamic way to load modules.
*/
const modules: FrontendModuleDescription[] = [InsightsModule, DataTableModule, MCPModule];
const modules: FrontendModuleDescription[] = [
InsightsModule,
DataTableModule,
MCPModule,
ChatModule,
];
/**
* Initialize modules resources (used in ResourcesListLayout), done in init.ts

View File

@ -133,6 +133,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isDataTableFeatureEnabled = computed(() => isModuleActive('data-table'));
const isChatFeatureEnabled = computed(() => isModuleActive('chat-hub'));
const areTagsEnabled = computed(() =>
settings.value.workflowTagsDisabled !== undefined ? !settings.value.workflowTagsDisabled : true,
);
@ -377,5 +379,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
activeModules,
isModuleActive,
isDataTableFeatureEnabled,
isChatFeatureEnabled,
};
});