mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-29 15:57:00 +02:00
feat(core): Add new 'chat-hub' module (no-changelog) (#20401)
This commit is contained in:
parent
3a9012819b
commit
cc3a97c044
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const MODULE_NAMES = [
|
|||
'community-packages',
|
||||
'data-table',
|
||||
'mcp',
|
||||
'chat-hub',
|
||||
] as const;
|
||||
|
||||
export type ModuleName = (typeof MODULE_NAMES)[number];
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
|
|||
"mcpApiKey:create",
|
||||
"mcpApiKey:rotate",
|
||||
"mcpApiKey:*",
|
||||
"chatHub:manage",
|
||||
"chatHub:*",
|
||||
"*",
|
||||
]
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
80
packages/cli/src/modules/chat-hub/chat-hub.controller.ts
Normal file
80
packages/cli/src/modules/chat-hub/chat-hub.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/cli/src/modules/chat-hub/chat-hub.module.ts
Normal file
31
packages/cli/src/modules/chat-hub/chat-hub.module.ts
Normal 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() {}
|
||||
}
|
||||
23
packages/cli/src/modules/chat-hub/chat-hub.service.ts
Normal file
23
packages/cli/src/modules/chat-hub/chat-hub.service.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
4
packages/cli/src/modules/chat-hub/chat-hub.types.ts
Normal file
4
packages/cli/src/modules/chat-hub/chat-hub.types.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface ChatPayload {
|
||||
message: string;
|
||||
model: string;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class UpdateChatSettingsDto extends Z.class({
|
||||
chatAccessEnabled: z.boolean(),
|
||||
}) {}
|
||||
|
|
@ -44,6 +44,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
|||
role: {},
|
||||
mcp: {},
|
||||
mcpApiKey: {},
|
||||
chatHub: {},
|
||||
});
|
||||
|
||||
function addGlobalRole(role: Role) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user