feat(core): Add new 'chat-hub' module (no-changelog) (#20401)

This commit is contained in:
Jaakko Husso 2025-10-07 13:17:42 +03:00 committed by GitHub
parent 3a9012819b
commit cc3a97c044
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 224 additions and 2 deletions

View File

@ -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);
});

View File

@ -8,6 +8,7 @@ export const MODULE_NAMES = [
'community-packages',
'data-table',
'mcp',
'chat-hub',
] as const;
export type ModuleName = (typeof MODULE_NAMES)[number];

View File

@ -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`

View File

@ -145,6 +145,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"mcpApiKey:create",
"mcpApiKey:rotate",
"mcpApiKey:*",
"chatHub:manage",
"chatHub:*",
"*",
]
`;

View File

@ -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 = {

View File

@ -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();

View File

@ -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);

View File

@ -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();
}
}
}
}

View File

@ -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() {}
}

View File

@ -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',
},
];
}
}

View File

@ -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 };
}
}

View File

@ -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<boolean> {
const row = await this.settingsRepository.findByKey(KEY);
// Allowed by default
if (!row) return true;
return row.value === 'true';
}
async setEnabled(enabled: boolean): Promise<void> {
const value = enabled ? 'true' : 'false';
await this.settingsRepository.upsert({ key: KEY, value, loadOnStartup: true }, ['key']);
}
}

View File

@ -0,0 +1,4 @@
export interface ChatPayload {
message: string;
model: string;
}

View File

@ -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(),
}) {}

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class UpdateChatSettingsDto extends Z.class({
chatAccessEnabled: z.boolean(),
}) {}

View File

@ -44,6 +44,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
role: {},
mcp: {},
mcpApiKey: {},
chatHub: {},
});
function addGlobalRole(role: Role) {