Skip to content

Commit e0f000e

Browse files
autologiekonstantintieber
authored andcommitted
perf(editor): Render chat sidebar faster (no-changelog) (#22039)
1 parent 3da0fc8 commit e0f000e

File tree

15 files changed

+464
-97
lines changed

15 files changed

+464
-97
lines changed

packages/@n8n/api-types/src/chat-hub.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,16 @@ export interface ChatHubMessageDto {
275275
attachments: Array<{ fileName?: string; mimeType?: string }>;
276276
}
277277

278-
export type ChatHubConversationsResponse = ChatHubSessionDto[];
278+
export class ChatHubConversationsRequest extends Z.class({
279+
limit: z.coerce.number().int().min(1).max(100),
280+
cursor: z.string().uuid().optional(),
281+
}) {}
282+
283+
export interface ChatHubConversationsResponse {
284+
data: ChatHubSessionDto[];
285+
nextCursor: string | null;
286+
hasMore: boolean;
287+
}
279288

280289
export interface ChatHubConversationDto {
281290
messages: Record<ChatMessageId, ChatHubMessageDto>;

packages/@n8n/api-types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export {
3333
ChatHubRegenerateMessageRequest,
3434
ChatHubEditMessageRequest,
3535
ChatHubUpdateConversationRequest,
36+
ChatHubConversationsRequest,
3637
type ChatMessageId,
3738
type ChatSessionId,
3839
type ChatHubMessageDto,

packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts

Lines changed: 178 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ describe('chatHub', () => {
4848

4949
describe('getConversations', () => {
5050
it('should list empty conversations', async () => {
51-
const conversations = await chatHubService.getConversations(member.id);
51+
const conversations = await chatHubService.getConversations(member.id, 20);
5252
expect(conversations).toBeDefined();
53-
expect(conversations).toHaveLength(0);
53+
expect(conversations.data).toHaveLength(0);
5454
});
5555

5656
it("should list user's own conversations in expected order", async () => {
@@ -83,11 +83,182 @@ describe('chatHub', () => {
8383
tools: [],
8484
});
8585

86-
const conversations = await chatHubService.getConversations(member.id);
87-
expect(conversations).toHaveLength(3);
88-
expect(conversations[0].id).toBe(session1.id);
89-
expect(conversations[1].id).toBe(session2.id);
90-
expect(conversations[2].id).toBe(session3.id);
86+
const conversations = await chatHubService.getConversations(member.id, 20);
87+
expect(conversations.data).toHaveLength(3);
88+
expect(conversations.data[0].id).toBe(session1.id);
89+
expect(conversations.data[1].id).toBe(session2.id);
90+
expect(conversations.data[2].id).toBe(session3.id);
91+
});
92+
93+
describe('pagination', () => {
94+
it('should return hasMore=false and nextCursor=null when all sessions fit in one page', async () => {
95+
await sessionsRepository.createChatSession({
96+
id: crypto.randomUUID(),
97+
ownerId: member.id,
98+
title: 'session 1',
99+
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
100+
tools: [],
101+
});
102+
103+
const conversations = await chatHubService.getConversations(member.id, 10);
104+
105+
expect(conversations.data).toHaveLength(1);
106+
expect(conversations.hasMore).toBe(false);
107+
expect(conversations.nextCursor).toBeNull();
108+
});
109+
110+
it('should fetch next page using cursor', async () => {
111+
const session1 = await sessionsRepository.createChatSession({
112+
id: crypto.randomUUID(),
113+
ownerId: member.id,
114+
title: 'session 1',
115+
lastMessageAt: new Date('2025-01-05T00:00:00Z'),
116+
tools: [],
117+
});
118+
119+
const session2 = await sessionsRepository.createChatSession({
120+
id: crypto.randomUUID(),
121+
ownerId: member.id,
122+
title: 'session 2',
123+
lastMessageAt: new Date('2025-01-04T00:00:00Z'),
124+
tools: [],
125+
});
126+
127+
const session3 = await sessionsRepository.createChatSession({
128+
id: crypto.randomUUID(),
129+
ownerId: member.id,
130+
title: 'session 3',
131+
lastMessageAt: new Date('2025-01-03T00:00:00Z'),
132+
tools: [],
133+
});
134+
135+
const session4 = await sessionsRepository.createChatSession({
136+
id: crypto.randomUUID(),
137+
ownerId: member.id,
138+
title: 'session 4',
139+
lastMessageAt: new Date('2025-01-02T00:00:00Z'),
140+
tools: [],
141+
});
142+
143+
// First page
144+
const page1 = await chatHubService.getConversations(member.id, 2);
145+
expect(page1.data).toHaveLength(2);
146+
expect(page1.data[0].id).toBe(session1.id);
147+
expect(page1.data[1].id).toBe(session2.id);
148+
expect(page1.hasMore).toBe(true);
149+
expect(page1.nextCursor).toBe(session2.id);
150+
151+
// Second page using cursor
152+
const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!);
153+
expect(page2.data).toHaveLength(2);
154+
expect(page2.data[0].id).toBe(session3.id);
155+
expect(page2.data[1].id).toBe(session4.id);
156+
expect(page2.hasMore).toBe(false);
157+
expect(page2.nextCursor).toBeNull();
158+
});
159+
160+
it('should handle sessions with same lastMessageAt using id for ordering', async () => {
161+
const sameDate = new Date('2025-01-01T00:00:00Z');
162+
163+
const session1 = await sessionsRepository.createChatSession({
164+
id: '00000000-0000-0000-0000-000000000001',
165+
ownerId: member.id,
166+
title: 'Session 1',
167+
lastMessageAt: sameDate,
168+
tools: [],
169+
});
170+
171+
const session2 = await sessionsRepository.createChatSession({
172+
id: '00000000-0000-0000-0000-000000000002',
173+
ownerId: member.id,
174+
title: 'Session 2',
175+
lastMessageAt: sameDate,
176+
tools: [],
177+
});
178+
179+
const session3 = await sessionsRepository.createChatSession({
180+
id: '00000000-0000-0000-0000-000000000003',
181+
ownerId: member.id,
182+
title: 'Session 3',
183+
lastMessageAt: sameDate,
184+
tools: [],
185+
});
186+
187+
// Fetch first page
188+
const page1 = await chatHubService.getConversations(member.id, 2);
189+
expect(page1.data).toHaveLength(2);
190+
expect(page1.data[0].id).toBe(session1.id);
191+
expect(page1.data[1].id).toBe(session2.id);
192+
expect(page1.hasMore).toBe(true);
193+
194+
// Fetch second page
195+
const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!);
196+
expect(page2.data).toHaveLength(1);
197+
expect(page2.data[0].id).toBe(session3.id);
198+
expect(page2.hasMore).toBe(false);
199+
});
200+
201+
it('should throw error when cursor session does not exist', async () => {
202+
await sessionsRepository.createChatSession({
203+
id: crypto.randomUUID(),
204+
ownerId: member.id,
205+
title: 'session 1',
206+
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
207+
tools: [],
208+
});
209+
210+
const nonExistentCursor = '00000000-0000-0000-0000-000000000000';
211+
212+
await expect(
213+
chatHubService.getConversations(member.id, 10, nonExistentCursor),
214+
).rejects.toThrow('Cursor session not found');
215+
});
216+
217+
it('should throw error when cursor session belongs to different user', async () => {
218+
await sessionsRepository.createChatSession({
219+
id: crypto.randomUUID(),
220+
ownerId: member.id,
221+
title: 'Member Session',
222+
lastMessageAt: new Date('2025-01-02T00:00:00Z'),
223+
tools: [],
224+
});
225+
226+
const adminSession = await sessionsRepository.createChatSession({
227+
id: crypto.randomUUID(),
228+
ownerId: admin.id,
229+
title: 'Admin Session',
230+
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
231+
tools: [],
232+
});
233+
234+
await expect(
235+
chatHubService.getConversations(member.id, 10, adminSession.id),
236+
).rejects.toThrow('Cursor session not found');
237+
});
238+
239+
it('should handle sessions with null lastMessageAt', async () => {
240+
const session1 = await sessionsRepository.createChatSession({
241+
id: crypto.randomUUID(),
242+
ownerId: member.id,
243+
title: 'Session with date',
244+
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
245+
tools: [],
246+
});
247+
248+
const session2 = await sessionsRepository.createChatSession({
249+
id: crypto.randomUUID(),
250+
ownerId: member.id,
251+
title: 'Session without date',
252+
lastMessageAt: null,
253+
tools: [],
254+
});
255+
256+
const conversations = await chatHubService.getConversations(member.id, 10);
257+
258+
expect(conversations.data).toHaveLength(2);
259+
expect(conversations.data[0].id).toBe(session1.id);
260+
expect(conversations.data[1].id).toBe(session2.id);
261+
});
91262
});
92263
});
93264

packages/cli/src/modules/chat-hub/chat-hub.controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ChatMessageId,
1111
ChatHubCreateAgentRequest,
1212
ChatHubUpdateAgentRequest,
13+
ChatHubConversationsRequest,
1314
} from '@n8n/api-types';
1415
import { Logger } from '@n8n/backend-common';
1516
import { AuthenticatedRequest } from '@n8n/db';
@@ -22,6 +23,7 @@ import {
2223
Delete,
2324
Param,
2425
Patch,
26+
Query,
2527
} from '@n8n/decorators';
2628
import type { Response } from 'express';
2729
import { strict as assert } from 'node:assert';
@@ -59,8 +61,9 @@ export class ChatHubController {
5961
async getConversations(
6062
req: AuthenticatedRequest,
6163
_res: Response,
64+
@Query query: ChatHubConversationsRequest,
6265
): Promise<ChatHubConversationsResponse> {
63-
return await this.chatService.getConversations(req.user.id);
66+
return await this.chatService.getConversations(req.user.id, query.limit, query.cursor);
6467
}
6568

6669
@Get('/conversations/:sessionId')

packages/cli/src/modules/chat-hub/chat-hub.service.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,24 +1683,36 @@ export class ChatHubService {
16831683
/**
16841684
* Get all conversations for a user
16851685
*/
1686-
async getConversations(userId: string): Promise<ChatHubConversationsResponse> {
1687-
const sessions = await this.sessionRepository.getManyByUserId(userId);
1688-
1689-
return sessions.map((session) => ({
1690-
id: session.id,
1691-
title: session.title,
1692-
ownerId: session.ownerId,
1693-
lastMessageAt: session.lastMessageAt?.toISOString() ?? null,
1694-
credentialId: session.credentialId,
1695-
provider: session.provider,
1696-
model: session.model,
1697-
workflowId: session.workflowId,
1698-
agentId: session.agentId,
1699-
agentName: session.agentName,
1700-
createdAt: session.createdAt.toISOString(),
1701-
updatedAt: session.updatedAt.toISOString(),
1702-
tools: session.tools,
1703-
}));
1686+
async getConversations(
1687+
userId: string,
1688+
limit: number,
1689+
cursor?: string,
1690+
): Promise<ChatHubConversationsResponse> {
1691+
const sessions = await this.sessionRepository.getManyByUserId(userId, limit + 1, cursor);
1692+
1693+
const hasMore = sessions.length > limit;
1694+
const data = hasMore ? sessions.slice(0, limit) : sessions;
1695+
const nextCursor = hasMore ? data[data.length - 1].id : null;
1696+
1697+
return {
1698+
data: data.map((session) => ({
1699+
id: session.id,
1700+
title: session.title,
1701+
ownerId: session.ownerId,
1702+
lastMessageAt: session.lastMessageAt?.toISOString() ?? null,
1703+
credentialId: session.credentialId,
1704+
provider: session.provider,
1705+
model: session.model,
1706+
workflowId: session.workflowId,
1707+
agentId: session.agentId,
1708+
agentName: session.agentName,
1709+
createdAt: session.createdAt.toISOString(),
1710+
updatedAt: session.updatedAt.toISOString(),
1711+
tools: session.tools,
1712+
})),
1713+
nextCursor,
1714+
hasMore,
1715+
};
17041716
}
17051717

17061718
/**

packages/cli/src/modules/chat-hub/chat-session.repository.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { withTransaction } from '@n8n/db';
22
import { Service } from '@n8n/di';
33
import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
44

5+
import { NotFoundError } from '@/errors/response-errors/not-found.error';
6+
57
import { ChatHubSession } from './chat-hub-session.entity';
68

79
@Service()
@@ -56,11 +58,33 @@ export class ChatHubSessionRepository extends Repository<ChatHubSession> {
5658
});
5759
}
5860

59-
async getManyByUserId(userId: string) {
60-
return await this.find({
61-
where: { ownerId: userId },
62-
order: { lastMessageAt: 'DESC', id: 'ASC' },
63-
});
61+
async getManyByUserId(userId: string, limit: number, cursor?: string) {
62+
const queryBuilder = this.createQueryBuilder('session')
63+
.where('session.ownerId = :userId', { userId })
64+
.orderBy("COALESCE(session.lastMessageAt, '1970-01-01')", 'DESC')
65+
.addOrderBy('session.id', 'ASC');
66+
67+
if (cursor) {
68+
const cursorSession = await this.findOne({
69+
where: { id: cursor, ownerId: userId },
70+
});
71+
72+
if (!cursorSession) {
73+
throw new NotFoundError('Cursor session not found');
74+
}
75+
76+
queryBuilder.andWhere(
77+
'(session.lastMessageAt < :lastMessageAt OR (session.lastMessageAt = :lastMessageAt AND session.id > :id))',
78+
{
79+
lastMessageAt: cursorSession.lastMessageAt,
80+
id: cursorSession.id,
81+
},
82+
);
83+
}
84+
85+
queryBuilder.take(limit);
86+
87+
return await queryBuilder.getMany();
6488
}
6589

6690
async getOneById(id: string, userId: string, trx?: EntityManager) {

0 commit comments

Comments
 (0)