From cc3a97c0448e65096b436c38b3dbf39afca7f94e Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Tue, 7 Oct 2025 13:17:42 +0300 Subject: [PATCH] feat(core): Add new 'chat-hub' module (no-changelog) (#20401) --- .../modules/__tests__/module-registry.test.ts | 4 +- .../src/modules/modules.config.ts | 1 + .../@n8n/config/src/configs/logging.config.ts | 2 + .../scope-information.test.ts.snap | 2 + packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/roles/scopes/global-scopes.ee.ts | 1 + .../get-resource-permissions.test.ts | 2 + .../modules/chat-hub/chat-hub.controller.ts | 80 +++++++++++++++++++ .../src/modules/chat-hub/chat-hub.module.ts | 31 +++++++ .../src/modules/chat-hub/chat-hub.service.ts | 23 ++++++ .../chat-hub/chat-hub.settings.controller.ts | 40 ++++++++++ .../chat-hub/chat-hub.settings.service.ts | 21 +++++ .../src/modules/chat-hub/chat-hub.types.ts | 4 + .../chat-hub/dto/chat-message-request.dto.ts | 7 ++ .../chat-hub/dto/update-chat-settings.dto.ts | 6 ++ .../editor-ui/src/stores/rbac.store.ts | 1 + 16 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/modules/chat-hub/chat-hub.controller.ts create mode 100644 packages/cli/src/modules/chat-hub/chat-hub.module.ts create mode 100644 packages/cli/src/modules/chat-hub/chat-hub.service.ts create mode 100644 packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts create mode 100644 packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts create mode 100644 packages/cli/src/modules/chat-hub/chat-hub.types.ts create mode 100644 packages/cli/src/modules/chat-hub/dto/chat-message-request.dto.ts create mode 100644 packages/cli/src/modules/chat-hub/dto/update-chat-settings.dto.ts diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index 50644e89bf2..40f6cae38d7 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -15,8 +15,8 @@ beforeEach(() => { describe('eligibleModules', () => { it('should consider all default modules eligible', () => { - // 'mcp' isn't (yet) eligible module by default - const NON_DEFAULT_MODULES = ['mcp']; + // 'mcp' and 'chat-hub' aren't (yet) eligible modules by default + const NON_DEFAULT_MODULES = ['mcp', 'chat-hub']; const expectedModules = MODULE_NAMES.filter((name) => !NON_DEFAULT_MODULES.includes(name)); expect(Container.get(ModuleRegistry).eligibleModules).toEqual(expectedModules); }); diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index 3c95fa3e045..2816afef704 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -8,6 +8,7 @@ export const MODULE_NAMES = [ 'community-packages', 'data-table', 'mcp', + 'chat-hub', ] as const; export type ModuleName = (typeof MODULE_NAMES)[number]; diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index 9852c842be0..5e9131e27f4 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -25,6 +25,7 @@ export const LOG_SCOPES = [ 'cron', 'community-nodes', 'legacy-sqlite-execution-recovery', + 'chat-hub', ] as const; export type LogScope = (typeof LOG_SCOPES)[number]; @@ -118,6 +119,7 @@ export class LoggingConfig { * - `task-runner-py` * - `workflow-activation` * - `insights` + * - `chat-hub` * * @example * `N8N_LOG_SCOPES=license` diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 9be9d2c6434..e1cab5bbec6 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -145,6 +145,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "mcpApiKey:create", "mcpApiKey:rotate", "mcpApiKey:*", + "chatHub:manage", + "chatHub:*", "*", ] `; diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 953131ffa3e..4bae144875e 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -33,6 +33,7 @@ export const RESOURCES = { role: ['manage'] as const, mcp: ['manage'] as const, mcpApiKey: ['create', 'rotate'] as const, + chatHub: ['manage'] as const, } as const; export const API_KEY_RESOURCES = { diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index bfd7e502058..82966410df4 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -94,6 +94,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'mcp:manage', 'mcpApiKey:create', 'mcpApiKey:rotate', + 'chatHub:manage', ]; export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index 6ceb8896c89..cbf8a27bee4 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -38,6 +38,7 @@ describe('permissions', () => { mcp: {}, mcpApiKey: {}, role: {}, + chatHub: {}, }); }); it('getResourcePermissions', () => { @@ -147,6 +148,7 @@ describe('permissions', () => { execution: {}, workflowTags: {}, role: {}, + chatHub: {}, }; expect(getResourcePermissions(scopes)).toEqual(permissionRecord); diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts new file mode 100644 index 00000000000..d9bc64e5f7e --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -0,0 +1,80 @@ +import { AuthenticatedRequest } from '@n8n/db'; +import { RestController, Get, Post, Body } 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 }; + +@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('/agents/openai') + async ask( + req: AuthenticatedRequest, + res: FlushableResponse, + @Body payload: ChatMessageRequestDto, + ) { + try { + const abortController = new AbortController(); + const { signal } = abortController; + + 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, + }); + } else { + // If headers were already sent dont't send a second error response + res.end(); + } + } + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.module.ts b/packages/cli/src/modules/chat-hub/chat-hub.module.ts new file mode 100644 index 00000000000..b62d293f8b4 --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.module.ts @@ -0,0 +1,31 @@ +import { Logger } from '@n8n/backend-common'; +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule, OnShutdown } from '@n8n/decorators'; +import { Container } from '@n8n/di'; + +const YELLOW = '\x1b[33m'; +const CLEAR = '\x1b[0m'; +const WARNING_MESSAGE = + "[Chat] 'chat' module is experimental, undocumented and subject to change. " + + 'Before its official release any features may become inaccessible at any point, ' + + 'and using the module could compromise the stability of your system. Use at your own risk!'; + +@BackendModule({ name: 'chat-hub' }) +export class ChatHubModule implements ModuleInterface { + async init() { + const logger = Container.get(Logger).scoped('chat-hub'); + logger.warn(`${YELLOW}${WARNING_MESSAGE}${CLEAR}`); + + await import('./chat-hub.controller'); + await import('./chat-hub.settings.controller'); + } + + async settings() { + const { ChatHubSettingsService } = await import('./chat-hub.settings.service'); + const chatAccessEnabled = await Container.get(ChatHubSettingsService).getEnabled(); + return { chatAccessEnabled }; + } + + @OnShutdown() + async shutdown() {} +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts new file mode 100644 index 00000000000..e5f178484aa --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -0,0 +1,23 @@ +import { Service } from '@n8n/di'; +import { type IUser } from 'n8n-workflow'; + +import { type ChatPayload } from './chat-hub.types'; + +@Service() +export class ChatHubService { + constructor() {} + + async getModels() { + return await Promise.resolve(['gpt-3.5-turbo', 'gpt-4']); + } + + async *ask(_payload: ChatPayload, _user: IUser, _abortSignal?: AbortSignal) { + yield* [ + { + role: 'assistant', + type: 'message', + message: 'hello world', + }, + ]; + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts new file mode 100644 index 00000000000..70955454098 --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts @@ -0,0 +1,40 @@ +import { ModuleRegistry, Logger } from '@n8n/backend-common'; +import { type AuthenticatedRequest } from '@n8n/db'; +import { Body, Get, Patch, RestController, GlobalScope } from '@n8n/decorators'; + +import { ChatHubSettingsService } from './chat-hub.settings.service'; +import { UpdateChatSettingsDto } from './dto/update-chat-settings.dto'; + +@RestController('/chat') +export class ChatHubSettingsController { + constructor( + private readonly settings: ChatHubSettingsService, + private readonly logger: Logger, + private readonly moduleRegistry: ModuleRegistry, + ) {} + + @Get('/settings') + async getSettings() { + const chatAccessEnabled = await this.settings.getEnabled(); + return { chatAccessEnabled }; + } + + @Patch('/settings') + @GlobalScope('chatHub:manage') + async updateSettings( + _req: AuthenticatedRequest, + _res: Response, + @Body dto: UpdateChatSettingsDto, + ) { + const enabled = dto.chatAccessEnabled; + await this.settings.setEnabled(enabled); + try { + await this.moduleRegistry.refreshModuleSettings('chat-hub'); + } catch (error) { + this.logger.warn('Failed to sync chat settings to module registry', { + cause: error instanceof Error ? error.message : String(error), + }); + } + return { chatAccessEnabled: enabled }; + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts new file mode 100644 index 00000000000..b030d1b799f --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts @@ -0,0 +1,21 @@ +import { SettingsRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; + +const KEY = 'chat.access.enabled'; + +@Service() +export class ChatHubSettingsService { + constructor(private readonly settingsRepository: SettingsRepository) {} + + async getEnabled(): Promise { + const row = await this.settingsRepository.findByKey(KEY); + // Allowed by default + if (!row) return true; + return row.value === 'true'; + } + + async setEnabled(enabled: boolean): Promise { + const value = enabled ? 'true' : 'false'; + await this.settingsRepository.upsert({ key: KEY, value, loadOnStartup: true }, ['key']); + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts new file mode 100644 index 00000000000..e548f036271 --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -0,0 +1,4 @@ +export interface ChatPayload { + message: string; + model: string; +} diff --git a/packages/cli/src/modules/chat-hub/dto/chat-message-request.dto.ts b/packages/cli/src/modules/chat-hub/dto/chat-message-request.dto.ts new file mode 100644 index 00000000000..c8c154f063c --- /dev/null +++ b/packages/cli/src/modules/chat-hub/dto/chat-message-request.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ChatMessageRequestDto extends Z.class({ + message: z.string(), + model: z.string(), +}) {} diff --git a/packages/cli/src/modules/chat-hub/dto/update-chat-settings.dto.ts b/packages/cli/src/modules/chat-hub/dto/update-chat-settings.dto.ts new file mode 100644 index 00000000000..2d241f2e939 --- /dev/null +++ b/packages/cli/src/modules/chat-hub/dto/update-chat-settings.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class UpdateChatSettingsDto extends Z.class({ + chatAccessEnabled: z.boolean(), +}) {} diff --git a/packages/frontend/editor-ui/src/stores/rbac.store.ts b/packages/frontend/editor-ui/src/stores/rbac.store.ts index a5adfb4b197..52deaea5205 100644 --- a/packages/frontend/editor-ui/src/stores/rbac.store.ts +++ b/packages/frontend/editor-ui/src/stores/rbac.store.ts @@ -44,6 +44,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { role: {}, mcp: {}, mcpApiKey: {}, + chatHub: {}, }); function addGlobalRole(role: Role) {