From f7b3ff23040071ff803537b6469251fb6b70435d Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Tue, 14 Oct 2025 11:32:58 +0300 Subject: [PATCH] feat(core): Use the new chat repositories for storage (no-changelog) (#20655) --- packages/@n8n/api-types/src/chat-hub.ts | 2 + ...e.entity.ts => chat-hub-message.entity.ts} | 6 +- ...n.entity.ts => chat-hub-session.entity.ts} | 2 +- .../modules/chat-hub/chat-hub.controller.ts | 2 +- .../src/modules/chat-hub/chat-hub.module.ts | 7 ++ .../src/modules/chat-hub/chat-hub.service.ts | 90 ++++++++++++------- .../src/modules/chat-hub/chat-hub.types.ts | 9 ++ .../chat-hub/chat-message.repository.ts | 38 ++++++++ .../chat-hub/chat-session.repository.ts | 63 +++++++++++++ .../src/features/chatHub/chat.store.ts | 19 +++- .../src/features/chatHub/chat.types.ts | 3 + .../src/features/chatHub/chat.utils.ts | 2 +- .../chatHub/components/ModelSelector.vue | 2 +- 13 files changed, 204 insertions(+), 41 deletions(-) rename packages/cli/src/modules/chat-hub/{chat-message.entity.ts => chat-hub-message.entity.ts} (96%) rename packages/cli/src/modules/chat-hub/{chat-session.entity.ts => chat-hub-session.entity.ts} (97%) create mode 100644 packages/cli/src/modules/chat-hub/chat-message.repository.ts create mode 100644 packages/cli/src/modules/chat-hub/chat-session.repository.ts diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index cb89c107d04..32591a5886d 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -23,6 +23,7 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record = { export const chatHubConversationModelSchema = z.object({ provider: chatHubProviderSchema, model: z.string(), + workflowId: z.string().nullable().default(null), }); export type ChatHubConversationModel = z.infer; @@ -50,6 +51,7 @@ export class ChatHubSendMessageRequest extends Z.class({ sessionId: z.string().uuid(), message: z.string(), model: chatHubConversationModelSchema, + previousMessageId: z.string().uuid().nullable(), credentials: z.record( z.object({ id: z.string(), diff --git a/packages/cli/src/modules/chat-hub/chat-message.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts similarity index 96% rename from packages/cli/src/modules/chat-hub/chat-message.entity.ts rename to packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts index c9dbf1bd21e..69e3923fb32 100644 --- a/packages/cli/src/modules/chat-hub/chat-message.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts @@ -1,9 +1,9 @@ -import { ChatHubProvider } from '@n8n/api-types'; +import type { ChatHubProvider } from '@n8n/api-types'; import { ExecutionEntity, WithTimestampsAndStringId, WorkflowEntity } from '@n8n/db'; import { Column, Entity, ManyToOne, JoinColumn, OneToMany, type Relation } from '@n8n/typeorm'; -import { ChatHubMessageState } from './chat-hub.types'; -import { ChatHubSession } from './chat-session.entity'; +import type { ChatHubSession } from './chat-hub-session.entity'; +import type { ChatHubMessageState } from './chat-hub.types'; export type ChatHubMessageType = 'human' | 'ai' | 'system' | 'tool' | 'generic'; diff --git a/packages/cli/src/modules/chat-hub/chat-session.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts similarity index 97% rename from packages/cli/src/modules/chat-hub/chat-session.entity.ts rename to packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts index f5576ad0ef8..5af99cc6957 100644 --- a/packages/cli/src/modules/chat-hub/chat-session.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts @@ -8,7 +8,7 @@ import { } from '@n8n/db'; import { Column, Entity, ManyToOne, OneToMany, JoinColumn } from '@n8n/typeorm'; -import type { ChatHubMessage } from './chat-message.entity'; +import type { ChatHubMessage } from './chat-hub-message.entity'; @Entity({ name: 'chat_hub_sessions' }) export class ChatHubSession extends WithTimestampsAndStringId { diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index 57fa8b21804..488de29435a 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -49,7 +49,7 @@ export class ChatHubController { this.logger.info(`Chat send request received: ${JSON.stringify(payload)}`); try { - await this.chatService.askN8n(res, req.user, { + await this.chatService.respondMessage(res, req.user, { ...payload, userId: req.user.id, replyId, diff --git a/packages/cli/src/modules/chat-hub/chat-hub.module.ts b/packages/cli/src/modules/chat-hub/chat-hub.module.ts index b62d293f8b4..63ab0fe2e4d 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.module.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.module.ts @@ -26,6 +26,13 @@ export class ChatHubModule implements ModuleInterface { return { chatAccessEnabled }; } + async entities() { + const { ChatHubSession } = await import('./chat-hub-session.entity'); + const { ChatHubMessage } = await import('./chat-hub-message.entity'); + + return [ChatHubSession, ChatHubMessage]; + } + @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 index 6eeb9d664c4..1c227370300 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -31,19 +31,20 @@ import { } from 'n8n-workflow'; import { v4 as uuidv4 } from 'uuid'; -import type { ChatPayloadWithCredentials, ChatMessage } from './chat-hub.types'; +import { ChatHubSession } from './chat-hub-session.entity'; +import type { ChatPayloadWithCredentials, MessageRecord } from './chat-hub.types'; +import { ChatHubMessageRepository } from './chat-message.repository'; +import { ChatHubSessionRepository } from './chat-session.repository'; -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'; -import { CredentialsService } from '@/credentials/credentials.service'; import { ActiveExecutions } from '@/active-executions'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { CredentialsHelper } from '@/credentials-helper'; +import { getBase } from '@/workflow-execute-additional-data'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; @Service() export class ChatHubService { - private sesssions: Map; - constructor( private readonly logger: Logger, private readonly credentialsService: CredentialsService, @@ -54,9 +55,9 @@ export class ChatHubService { private readonly projectRepository: ProjectRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly activeExecutions: ActiveExecutions, - ) { - this.sesssions = new Map(); - } + private readonly sessionRepository: ChatHubSessionRepository, + private readonly messageRepository: ChatHubMessageRepository, + ) {} async getModels( user: User, @@ -293,23 +294,35 @@ export class ChatHubService { return undefined; } - async askN8n(res: Response, user: User, payload: ChatPayloadWithCredentials) { - let session = this.sesssions.get(payload.sessionId); - if (!session) { - session = []; - this.sesssions.set(payload.sessionId, session); + async respondMessage(res: Response, user: User, payload: ChatPayloadWithCredentials) { + const existing = await this.sessionRepository.getOneById(payload.sessionId, user.id); + const turnId = payload.messageId; + + // TODO: Handle session ID conflicts better (different user, same ID) + let session: ChatHubSession; + if (existing) { + session = existing; + } else { + session = await this.sessionRepository.createChatSession({ + id: payload.sessionId, + ownerId: user.id, + title: 'New Chat', + provider: payload.model.provider, + model: payload.model.model, + workflowId: payload.model.workflowId ?? null, + credentialId: payload.credentials?.[payload.model.provider]?.id ?? null, + }); } - const chatHistory = session.map((msg) => ({ - type: msg.type, - message: msg.message, - })); - - session.push({ + await this.messageRepository.createChatMessage({ id: payload.messageId, - message: payload.message, - type: 'user', - createdAt: new Date(), + sessionId: payload.sessionId, + type: 'human', + name: 'You', + content: payload.message, + state: 'active', + turnId, + previousMessageId: payload.previousMessageId ?? null, }); /* eslint-disable @typescript-eslint/naming-convention */ @@ -357,7 +370,20 @@ export class ChatHubService { parameters: { mode: 'insert', messages: { - messageValues: chatHistory, + messageValues: session.messages?.map((message) => { + const typeMap: Record = { + human: 'user', + ai: 'ai', + system: 'system', + }; + + // TODO: Tools ? + return { + type: typeMap[message.type] || 'system', + message: message.content, + hideFromUI: false, + }; + }), }, }, type: '@n8n/n8n-nodes-langchain.memoryManager', @@ -456,12 +482,16 @@ export class ChatHubService { const message = this.getMessage(execution); if (message) { - this.logger.debug(`Assistant: ${message} (${payload.replyId})`); - session.push({ + await this.messageRepository.createChatMessage({ id: payload.replyId, - message, + sessionId: payload.sessionId, type: 'ai', - createdAt: new Date(), + name: 'AI', + content: message, + state: 'active', + turnId, + executionId: parseInt(execution.id, 10), + previousMessageId: payload.messageId, }); } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts index fe83d50007d..0a0ea8a8208 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.types.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -7,6 +7,7 @@ export interface ChatPayloadWithCredentials { messageId: string; sessionId: string; replyId: string; + previousMessageId: string | null; model: ChatHubConversationModel; credentials: INodeCredentials; } @@ -19,3 +20,11 @@ export type ChatMessage = { }; export type ChatHubMessageState = 'active' | 'superseded' | 'hidden' | 'deleted'; + +// From packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts +export type MessageRole = 'ai' | 'system' | 'user'; +export interface MessageRecord { + type: MessageRole; + message: string; + hideFromUI: boolean; +} diff --git a/packages/cli/src/modules/chat-hub/chat-message.repository.ts b/packages/cli/src/modules/chat-hub/chat-message.repository.ts new file mode 100644 index 00000000000..a37e539433a --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-message.repository.ts @@ -0,0 +1,38 @@ +import { withTransaction } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; + +import { ChatHubMessage } from './chat-hub-message.entity'; +import { ChatHubSessionRepository } from './chat-session.repository'; + +@Service() +export class ChatHubMessageRepository extends Repository { + constructor( + dataSource: DataSource, + private chatSessionRepository: ChatHubSessionRepository, + ) { + super(ChatHubMessage, dataSource.manager); + } + + async createChatMessage(message: Partial, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + const chatMessage = em.create(ChatHubMessage, message); + const saved = await em.save(chatMessage); + await this.chatSessionRepository.updateLastMessageAt(saved.sessionId, saved.createdAt, em); + return saved; + }); + } + + async deleteChatMessage(id: string, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + return await em.delete(ChatHubMessage, { id }); + }); + } + + async getManyBySessionId(sessionId: string) { + return await this.find({ + where: { sessionId }, + order: { createdAt: 'ASC' }, + }); + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-session.repository.ts b/packages/cli/src/modules/chat-hub/chat-session.repository.ts new file mode 100644 index 00000000000..6aae156ecee --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-session.repository.ts @@ -0,0 +1,63 @@ +import { withTransaction } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; + +import { ChatHubSession } from './chat-hub-session.entity'; + +@Service() +export class ChatHubSessionRepository extends Repository { + constructor(dataSource: DataSource) { + super(ChatHubSession, dataSource.manager); + } + + async createChatSession(session: Partial, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + const chatHubSession = em.create(ChatHubSession, session); + const saved = await em.save(chatHubSession); + return await em.findOneOrFail(ChatHubSession, { + where: { id: saved.id }, + relations: ['messages'], + }); + }); + } + + async updateLastMessageAt(id: string, lastMessageAt: Date, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + await em.update(ChatHubSession, { id }, { lastMessageAt }); + return await em.findOneOrFail(ChatHubSession, { + where: { id }, + relations: ['messages'], + }); + }); + } + + async updateChatTitle(id: string, title: string, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + await em.update(ChatHubSession, { id }, { title }); + return await em.findOneOrFail(ChatHubSession, { + where: { id }, + relations: ['messages'], + }); + }); + } + + async deleteChatHubSession(id: string, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + return await em.delete(ChatHubSession, { id }); + }); + } + + async getManyByUserId(userId: string) { + return await this.find({ + where: { ownerId: userId }, + order: { lastMessageAt: 'DESC' }, + }); + } + + async getOneById(id: string, userId: string) { + return await this.findOne({ + where: { id, ownerId: userId }, + relations: ['messages'], + }); + } +} diff --git a/packages/frontend/editor-ui/src/features/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/chatHub/chat.store.ts index 8f040d23f21..656eb40443d 100644 --- a/packages/frontend/editor-ui/src/features/chatHub/chat.store.ts +++ b/packages/frontend/editor-ui/src/features/chatHub/chat.store.ts @@ -25,6 +25,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => { const messagesBySession = ref>>({}); const sessions = ref([]); + const getLastMessage = (sessionId: string) => { + const msgs = messagesBySession.value[sessionId]; + if (!msgs || msgs.length === 0) return null; + return msgs[msgs.length - 1]; + }; + async function fetchChatModels(credentialMap: CredentialsMap) { loadingModels.value = true; models.value = await fetchChatModelsApi(rootStore.restApiContext, { @@ -48,6 +54,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { role: msg.role, type: 'message' as const, text: msg.content, + key: msg.id, })), }; } @@ -59,6 +66,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { ...(messagesBySession.value[sessionId] ?? []), { id, + key: id, role: 'user', type: 'message', text: content, @@ -67,13 +75,14 @@ export const useChatStore = defineStore(CHAT_STORE, () => { }; } - function addAiMessage(sessionId: string, content: string, id: string) { + function addAiMessage(sessionId: string, content: string, id: string, key: string) { messagesBySession.value = { ...messagesBySession.value, [sessionId]: [ ...(messagesBySession.value[sessionId] ?? []), { id, + key, role: 'assistant', type: 'message', text: content, @@ -82,11 +91,11 @@ export const useChatStore = defineStore(CHAT_STORE, () => { }; } - function appendMessage(sessionId: string, content: string, id: string) { + function appendMessage(sessionId: string, content: string, key: string) { messagesBySession.value = { ...messagesBySession.value, [sessionId]: (messagesBySession.value[sessionId] ?? []).map((msg) => { - if (msg.id === id && msg.type === 'message') { + if (msg.key === key && msg.type === 'message') { return { ...msg, text: msg.text + content, @@ -99,7 +108,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { function onBeginMessage(sessionId: string, messageId: string, nodeId: string, runIndex?: number) { isResponding.value = true; - addAiMessage(sessionId, '', `${messageId}-${nodeId}-${runIndex ?? 0}`); + addAiMessage(sessionId, '', messageId, `${messageId}-${nodeId}-${runIndex ?? 0}`); } function onChunk( @@ -162,6 +171,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { credentials: ChatHubSendMessageRequest['credentials'], ) { const messageId = uuidv4(); + const previousMessageId = getLastMessage(sessionId)?.id ?? null; addUserMessage(sessionId, message, messageId); @@ -173,6 +183,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { sessionId, message, credentials, + previousMessageId, }, (chunk: StructuredChunk) => onStreamMessage(sessionId, chunk, messageId), onStreamDone, diff --git a/packages/frontend/editor-ui/src/features/chatHub/chat.types.ts b/packages/frontend/editor-ui/src/features/chatHub/chat.types.ts index ab4a09392f2..9445bde1eb9 100644 --- a/packages/frontend/editor-ui/src/features/chatHub/chat.types.ts +++ b/packages/frontend/editor-ui/src/features/chatHub/chat.types.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; export interface UserMessage { id: string; + key: string; role: 'user'; type: 'message'; text: string; @@ -10,6 +11,7 @@ export interface UserMessage { export interface AssistantMessage { id: string; + key: string; role: 'assistant'; type: 'message'; text: string; @@ -17,6 +19,7 @@ export interface AssistantMessage { export interface ErrorMessage { id: string; + key: string; role: 'assistant'; type: 'error'; content: string; diff --git a/packages/frontend/editor-ui/src/features/chatHub/chat.utils.ts b/packages/frontend/editor-ui/src/features/chatHub/chat.utils.ts index d1fb86783d9..3c123c0e18a 100644 --- a/packages/frontend/editor-ui/src/features/chatHub/chat.utils.ts +++ b/packages/frontend/editor-ui/src/features/chatHub/chat.utils.ts @@ -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 }; + return { model: response[provider].models[0].name, provider, workflowId: null }; } } diff --git a/packages/frontend/editor-ui/src/features/chatHub/components/ModelSelector.vue b/packages/frontend/editor-ui/src/features/chatHub/components/ModelSelector.vue index e082c99bfc8..d9bf71585c1 100644 --- a/packages/frontend/editor-ui/src/features/chatHub/components/ModelSelector.vue +++ b/packages/frontend/editor-ui/src/features/chatHub/components/ModelSelector.vue @@ -75,7 +75,7 @@ function onSelect(id: string) { return; } - emit('change', { provider: parsedProvider, model }); + emit('change', { provider: parsedProvider, model, workflowId: null }); }