mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 16:26:59 +02:00
perf(editor): Render chat sidebar faster (no-changelog) (#22039)
This commit is contained in:
parent
11e898eafe
commit
11e111e372
|
|
@ -275,7 +275,16 @@ export interface ChatHubMessageDto {
|
|||
attachments: Array<{ fileName?: string; mimeType?: string }>;
|
||||
}
|
||||
|
||||
export type ChatHubConversationsResponse = ChatHubSessionDto[];
|
||||
export class ChatHubConversationsRequest extends Z.class({
|
||||
limit: z.coerce.number().int().min(1).max(100),
|
||||
cursor: z.string().uuid().optional(),
|
||||
}) {}
|
||||
|
||||
export interface ChatHubConversationsResponse {
|
||||
data: ChatHubSessionDto[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ChatHubConversationDto {
|
||||
messages: Record<ChatMessageId, ChatHubMessageDto>;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export {
|
|||
ChatHubRegenerateMessageRequest,
|
||||
ChatHubEditMessageRequest,
|
||||
ChatHubUpdateConversationRequest,
|
||||
ChatHubConversationsRequest,
|
||||
type ChatMessageId,
|
||||
type ChatSessionId,
|
||||
type ChatHubMessageDto,
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ describe('chatHub', () => {
|
|||
|
||||
describe('getConversations', () => {
|
||||
it('should list empty conversations', async () => {
|
||||
const conversations = await chatHubService.getConversations(member.id);
|
||||
const conversations = await chatHubService.getConversations(member.id, 20);
|
||||
expect(conversations).toBeDefined();
|
||||
expect(conversations).toHaveLength(0);
|
||||
expect(conversations.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should list user's own conversations in expected order", async () => {
|
||||
|
|
@ -83,11 +83,182 @@ describe('chatHub', () => {
|
|||
tools: [],
|
||||
});
|
||||
|
||||
const conversations = await chatHubService.getConversations(member.id);
|
||||
expect(conversations).toHaveLength(3);
|
||||
expect(conversations[0].id).toBe(session1.id);
|
||||
expect(conversations[1].id).toBe(session2.id);
|
||||
expect(conversations[2].id).toBe(session3.id);
|
||||
const conversations = await chatHubService.getConversations(member.id, 20);
|
||||
expect(conversations.data).toHaveLength(3);
|
||||
expect(conversations.data[0].id).toBe(session1.id);
|
||||
expect(conversations.data[1].id).toBe(session2.id);
|
||||
expect(conversations.data[2].id).toBe(session3.id);
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('should return hasMore=false and nextCursor=null when all sessions fit in one page', async () => {
|
||||
await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'session 1',
|
||||
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const conversations = await chatHubService.getConversations(member.id, 10);
|
||||
|
||||
expect(conversations.data).toHaveLength(1);
|
||||
expect(conversations.hasMore).toBe(false);
|
||||
expect(conversations.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('should fetch next page using cursor', async () => {
|
||||
const session1 = await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'session 1',
|
||||
lastMessageAt: new Date('2025-01-05T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const session2 = await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'session 2',
|
||||
lastMessageAt: new Date('2025-01-04T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const session3 = await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'session 3',
|
||||
lastMessageAt: new Date('2025-01-03T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const session4 = await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'session 4',
|
||||
lastMessageAt: new Date('2025-01-02T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
// First page
|
||||
const page1 = await chatHubService.getConversations(member.id, 2);
|
||||
expect(page1.data).toHaveLength(2);
|
||||
expect(page1.data[0].id).toBe(session1.id);
|
||||
expect(page1.data[1].id).toBe(session2.id);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
expect(page1.nextCursor).toBe(session2.id);
|
||||
|
||||
// Second page using cursor
|
||||
const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!);
|
||||
expect(page2.data).toHaveLength(2);
|
||||
expect(page2.data[0].id).toBe(session3.id);
|
||||
expect(page2.data[1].id).toBe(session4.id);
|
||||
expect(page2.hasMore).toBe(false);
|
||||
expect(page2.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle sessions with same lastMessageAt using id for ordering', async () => {
|
||||
const sameDate = new Date('2025-01-01T00:00:00Z');
|
||||
|
||||
const session1 = await sessionsRepository.createChatSession({
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
ownerId: member.id,
|
||||
title: 'Session 1',
|
||||
lastMessageAt: sameDate,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const session2 = await sessionsRepository.createChatSession({
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
ownerId: member.id,
|
||||
title: 'Session 2',
|
||||
lastMessageAt: sameDate,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const session3 = await sessionsRepository.createChatSession({
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
ownerId: member.id,
|
||||
title: 'Session 3',
|
||||
lastMessageAt: sameDate,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
// Fetch first page
|
||||
const page1 = await chatHubService.getConversations(member.id, 2);
|
||||
expect(page1.data).toHaveLength(2);
|
||||
expect(page1.data[0].id).toBe(session1.id);
|
||||
expect(page1.data[1].id).toBe(session2.id);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
|
||||
// Fetch second page
|
||||
const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!);
|
||||
expect(page2.data).toHaveLength(1);
|
||||
expect(page2.data[0].id).toBe(session3.id);
|
||||
expect(page2.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error when cursor session does not exist', async () => {
|
||||
await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'session 1',
|
||||
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const nonExistentCursor = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
await expect(
|
||||
chatHubService.getConversations(member.id, 10, nonExistentCursor),
|
||||
).rejects.toThrow('Cursor session not found');
|
||||
});
|
||||
|
||||
it('should throw error when cursor session belongs to different user', async () => {
|
||||
await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'Member Session',
|
||||
lastMessageAt: new Date('2025-01-02T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const adminSession = await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: admin.id,
|
||||
title: 'Admin Session',
|
||||
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
chatHubService.getConversations(member.id, 10, adminSession.id),
|
||||
).rejects.toThrow('Cursor session not found');
|
||||
});
|
||||
|
||||
it('should handle sessions with null lastMessageAt', async () => {
|
||||
const session1 = await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'Session with date',
|
||||
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const session2 = await sessionsRepository.createChatSession({
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: member.id,
|
||||
title: 'Session without date',
|
||||
lastMessageAt: null,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const conversations = await chatHubService.getConversations(member.id, 10);
|
||||
|
||||
expect(conversations.data).toHaveLength(2);
|
||||
expect(conversations.data[0].id).toBe(session1.id);
|
||||
expect(conversations.data[1].id).toBe(session2.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChatMessageId,
|
||||
ChatHubCreateAgentRequest,
|
||||
ChatHubUpdateAgentRequest,
|
||||
ChatHubConversationsRequest,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
Delete,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
} from '@n8n/decorators';
|
||||
import type { Response } from 'express';
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
|
@ -59,8 +61,9 @@ export class ChatHubController {
|
|||
async getConversations(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Query query: ChatHubConversationsRequest,
|
||||
): Promise<ChatHubConversationsResponse> {
|
||||
return await this.chatService.getConversations(req.user.id);
|
||||
return await this.chatService.getConversations(req.user.id, query.limit, query.cursor);
|
||||
}
|
||||
|
||||
@Get('/conversations/:sessionId')
|
||||
|
|
|
|||
|
|
@ -1683,24 +1683,36 @@ export class ChatHubService {
|
|||
/**
|
||||
* Get all conversations for a user
|
||||
*/
|
||||
async getConversations(userId: string): Promise<ChatHubConversationsResponse> {
|
||||
const sessions = await this.sessionRepository.getManyByUserId(userId);
|
||||
async getConversations(
|
||||
userId: string,
|
||||
limit: number,
|
||||
cursor?: string,
|
||||
): Promise<ChatHubConversationsResponse> {
|
||||
const sessions = await this.sessionRepository.getManyByUserId(userId, limit + 1, cursor);
|
||||
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
ownerId: session.ownerId,
|
||||
lastMessageAt: session.lastMessageAt?.toISOString() ?? null,
|
||||
credentialId: session.credentialId,
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
workflowId: session.workflowId,
|
||||
agentId: session.agentId,
|
||||
agentName: session.agentName,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString(),
|
||||
tools: session.tools,
|
||||
}));
|
||||
const hasMore = sessions.length > limit;
|
||||
const data = hasMore ? sessions.slice(0, limit) : sessions;
|
||||
const nextCursor = hasMore ? data[data.length - 1].id : null;
|
||||
|
||||
return {
|
||||
data: data.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
ownerId: session.ownerId,
|
||||
lastMessageAt: session.lastMessageAt?.toISOString() ?? null,
|
||||
credentialId: session.credentialId,
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
workflowId: session.workflowId,
|
||||
agentId: session.agentId,
|
||||
agentName: session.agentName,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString(),
|
||||
tools: session.tools,
|
||||
})),
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { withTransaction } from '@n8n/db';
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { ChatHubSession } from './chat-hub-session.entity';
|
||||
|
||||
@Service()
|
||||
|
|
@ -56,11 +58,33 @@ export class ChatHubSessionRepository extends Repository<ChatHubSession> {
|
|||
});
|
||||
}
|
||||
|
||||
async getManyByUserId(userId: string) {
|
||||
return await this.find({
|
||||
where: { ownerId: userId },
|
||||
order: { lastMessageAt: 'DESC', id: 'ASC' },
|
||||
});
|
||||
async getManyByUserId(userId: string, limit: number, cursor?: string) {
|
||||
const queryBuilder = this.createQueryBuilder('session')
|
||||
.where('session.ownerId = :userId', { userId })
|
||||
.orderBy("COALESCE(session.lastMessageAt, '1970-01-01')", 'DESC')
|
||||
.addOrderBy('session.id', 'ASC');
|
||||
|
||||
if (cursor) {
|
||||
const cursorSession = await this.findOne({
|
||||
where: { id: cursor, ownerId: userId },
|
||||
});
|
||||
|
||||
if (!cursorSession) {
|
||||
throw new NotFoundError('Cursor session not found');
|
||||
}
|
||||
|
||||
queryBuilder.andWhere(
|
||||
'(session.lastMessageAt < :lastMessageAt OR (session.lastMessageAt = :lastMessageAt AND session.id > :id))',
|
||||
{
|
||||
lastMessageAt: cursorSession.lastMessageAt,
|
||||
id: cursorSession.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.take(limit);
|
||||
|
||||
return await queryBuilder.getMany();
|
||||
}
|
||||
|
||||
async getOneById(id: string, userId: string, trx?: EntityManager) {
|
||||
|
|
|
|||
|
|
@ -141,19 +141,19 @@ const modelFromQuery = computed<ChatModelDto | null>(() => {
|
|||
}
|
||||
|
||||
if (typeof agentId === 'string') {
|
||||
return chatStore.getAgent({ provider: 'custom-agent', agentId }) ?? null;
|
||||
return chatStore.getAgent({ provider: 'custom-agent', agentId });
|
||||
}
|
||||
|
||||
if (typeof workflowId === 'string') {
|
||||
return chatStore.getAgent({ provider: 'n8n', workflowId }) ?? null;
|
||||
return chatStore.getAgent({ provider: 'n8n', workflowId });
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const selectedModel = computed<ChatModelDto | undefined>(() => {
|
||||
const selectedModel = computed<ChatModelDto | null>(() => {
|
||||
if (!chatStore.agentsReady) {
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (modelFromQuery.value) {
|
||||
|
|
@ -163,14 +163,14 @@ const selectedModel = computed<ChatModelDto | undefined>(() => {
|
|||
if (currentConversation.value?.provider) {
|
||||
const model = unflattenModel(currentConversation.value);
|
||||
|
||||
return model ? chatStore.getAgent(model) : undefined;
|
||||
return model ? chatStore.getAgent(model) : null;
|
||||
}
|
||||
|
||||
if (chatStore.streaming?.sessionId === sessionId.value) {
|
||||
return chatStore.getAgent(chatStore.streaming.model);
|
||||
}
|
||||
|
||||
return defaultModel.value ? chatStore.getAgent(defaultModel.value) : undefined;
|
||||
return defaultModel.value ? chatStore.getAgent(defaultModel.value) : null;
|
||||
});
|
||||
|
||||
const { credentialsByProvider, selectCredential } = useChatCredentials(
|
||||
|
|
@ -501,7 +501,7 @@ function onFilesDropped(files: File[]) {
|
|||
|
||||
<ChatConversationHeader
|
||||
ref="headerRef"
|
||||
:selected-model="selectedModel ?? null"
|
||||
:selected-model="selectedModel"
|
||||
:credentials="credentialsByProvider"
|
||||
:ready-to-show-model-selector="chatStore.agentsReady"
|
||||
@select-model="handleSelectModel"
|
||||
|
|
@ -563,7 +563,7 @@ function onFilesDropped(files: File[]) {
|
|||
<ChatPrompt
|
||||
ref="inputRef"
|
||||
:class="$style.prompt"
|
||||
:selected-model="selectedModel ?? null"
|
||||
:selected-model="selectedModel"
|
||||
:selected-tools="selectedTools"
|
||||
:is-responding="isResponding"
|
||||
:is-tools-selectable="canSelectTools"
|
||||
|
|
|
|||
|
|
@ -97,8 +97,16 @@ export const stopGenerationApi = async (
|
|||
|
||||
export const fetchConversationsApi = async (
|
||||
context: IRestApiContext,
|
||||
limit: number,
|
||||
cursor?: string,
|
||||
): Promise<ChatHubConversationsResponse> => {
|
||||
const apiEndpoint = '/chat/conversations';
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('limit', limit.toString());
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor);
|
||||
}
|
||||
|
||||
const apiEndpoint = `/chat/conversations?${queryParams.toString()}`;
|
||||
return await makeRestApiRequest<ChatHubConversationsResponse>(context, 'GET', apiEndpoint);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
type EnrichedStructuredChunk,
|
||||
type ChatHubMessageStatus,
|
||||
type ChatModelDto,
|
||||
type ChatHubConversationsResponse,
|
||||
} from '@n8n/api-types';
|
||||
import type {
|
||||
CredentialsMap,
|
||||
|
|
@ -56,7 +57,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
const telemetry = useTelemetry();
|
||||
|
||||
const agents = ref<ChatModelsResponse>();
|
||||
const sessions = ref<ChatHubSessionDto[]>();
|
||||
const sessions = ref<ChatHubConversationsResponse>();
|
||||
const sessionsLoadingMore = ref(false);
|
||||
|
||||
const currentEditingAgent = ref<ChatHubAgentDto | null>(null);
|
||||
const streaming = ref<ChatStreamingState>();
|
||||
|
||||
|
|
@ -270,8 +273,39 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
return agents.value;
|
||||
}
|
||||
|
||||
async function fetchSessions() {
|
||||
sessions.value = await fetchSessionsApi(rootStore.restApiContext);
|
||||
async function fetchSessions(reset: boolean) {
|
||||
if (sessionsLoadingMore.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reset && sessions.value && !sessions.value.hasMore && sessions.value.data.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reset) {
|
||||
sessionsLoadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const cursor = reset ? undefined : (sessions.value?.nextCursor ?? undefined);
|
||||
const [response] = await Promise.all([
|
||||
fetchSessionsApi(rootStore.restApiContext, 40, cursor),
|
||||
new Promise((resolve) => setTimeout(resolve, 500)),
|
||||
]);
|
||||
|
||||
sessions.value = {
|
||||
...response,
|
||||
data: [...(reset ? [] : (sessions.value?.data ?? [])), ...response.data],
|
||||
};
|
||||
} finally {
|
||||
sessionsLoadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMoreSessions() {
|
||||
if (sessions.value?.hasMore && !sessionsLoadingMore.value) {
|
||||
await fetchSessions(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages(sessionId: string) {
|
||||
|
|
@ -302,25 +336,30 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
|
||||
addMessage(streaming.value.sessionId, message);
|
||||
|
||||
if (sessions.value?.some((session) => session.id === streaming.value?.sessionId)) {
|
||||
if (sessions.value?.data.some((session) => session.id === streaming.value?.sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.value = [
|
||||
...(sessions.value ?? []),
|
||||
{
|
||||
id: streaming.value.sessionId,
|
||||
title: 'New Chat',
|
||||
ownerId: '',
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
credentialId: null,
|
||||
agentName: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tools: [],
|
||||
...flattenModel(streaming.value.model),
|
||||
},
|
||||
];
|
||||
sessions.value = {
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
...sessions.value,
|
||||
data: [
|
||||
...(sessions.value?.data ?? []),
|
||||
{
|
||||
id: streaming.value.sessionId,
|
||||
title: 'New Chat',
|
||||
ownerId: '',
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
credentialId: null,
|
||||
agentName: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tools: [],
|
||||
...flattenModel(streaming.value.model),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function ensureMessage(sessionId: ChatSessionId, messageId: ChatMessageId): ChatMessage {
|
||||
|
|
@ -402,8 +441,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
3,
|
||||
);
|
||||
|
||||
// update the conversation list
|
||||
await fetchSessions();
|
||||
// update the conversation list to reflect the new title
|
||||
await fetchSessions(true);
|
||||
}
|
||||
|
||||
function onStreamError(error: Error) {
|
||||
|
|
@ -607,7 +646,11 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
}
|
||||
|
||||
function updateSession(sessionId: ChatSessionId, toUpdate: Partial<ChatHubSessionDto>) {
|
||||
sessions.value = sessions.value?.map((session) =>
|
||||
if (!sessions.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.value.data = sessions.value.data?.map((session) =>
|
||||
session.id === sessionId
|
||||
? {
|
||||
...session,
|
||||
|
|
@ -618,7 +661,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
}
|
||||
|
||||
async function updateToolsInSession(sessionId: ChatSessionId, tools: INode[]) {
|
||||
const session = sessions.value?.find((s) => s.id === sessionId);
|
||||
const session = sessions.value?.data?.find((s) => s.id === sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session with ID ${sessionId} not found`);
|
||||
}
|
||||
|
|
@ -644,7 +687,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
async function deleteSession(sessionId: ChatSessionId) {
|
||||
await deleteConversationApi(rootStore.restApiContext, sessionId);
|
||||
|
||||
sessions.value = sessions.value?.filter((session) => session.id !== sessionId);
|
||||
if (sessions.value) {
|
||||
sessions.value = {
|
||||
...sessions.value,
|
||||
data: sessions.value.data?.filter((session) => session.id !== sessionId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function switchAlternative(sessionId: ChatSessionId, messageId: ChatMessageId) {
|
||||
|
|
@ -730,13 +778,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
}
|
||||
|
||||
function getAgent(model: ChatHubConversationModel) {
|
||||
if (!agents.value) return;
|
||||
if (!agents.value) return null;
|
||||
|
||||
const agent = agents.value[model.provider].models.find((agent) => isMatchedAgent(agent, model));
|
||||
|
||||
if (!agent) {
|
||||
if (model.provider === 'custom-agent' || model.provider === 'n8n') {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Allow custom models chosen by ID even if they are not in the fetched list
|
||||
|
|
@ -774,9 +822,11 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
/**
|
||||
* conversations
|
||||
*/
|
||||
sessions: computed(() => sessions.value ?? []),
|
||||
sessions: computed(() => sessions.value?.data ?? []),
|
||||
sessionsReady: computed(() => sessions.value !== undefined),
|
||||
sessionsLoading: computed(() => sessionsLoadingMore.value),
|
||||
fetchSessions,
|
||||
fetchMoreSessions,
|
||||
renameSession,
|
||||
updateSessionModel,
|
||||
deleteSession,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function loadAgent() {
|
|||
name.value = customAgent.name;
|
||||
description.value = customAgent.description ?? '';
|
||||
systemPrompt.value = customAgent.systemPrompt;
|
||||
selectedModel.value = chatStore.getAgent(customAgent) ?? null;
|
||||
selectedModel.value = chatStore.getAgent(customAgent);
|
||||
tools.value = customAgent.tools || [];
|
||||
|
||||
if (customAgent.credentialId) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useClipboard } from '@/app/composables/useClipboard';
|
|||
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
|
||||
import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue';
|
||||
import { useChatHubMarkdownOptions } from '@/features/ai/chatHub/composables/useChatHubMarkdownOptions';
|
||||
import type { ChatMessageId } from '@n8n/api-types';
|
||||
import type { ChatMessageId, ChatModelDto } from '@n8n/api-types';
|
||||
import { N8nButton, N8nIcon, N8nInput } from '@n8n/design-system';
|
||||
import { useSpeechSynthesis } from '@vueuse/core';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
|
|
@ -13,7 +13,7 @@ import VueMarkdown from 'vue-markdown-render';
|
|||
import type { ChatMessage } from '../chat.types';
|
||||
import ChatMessageActions from './ChatMessageActions.vue';
|
||||
import { unflattenModel } from '@/features/ai/chatHub/chat.utils';
|
||||
import { useAgent } from '@/features/ai/chatHub/composables/useAgent';
|
||||
import { useChatStore } from '@/features/ai/chatHub/chat.store';
|
||||
import ChatFile from '@n8n/chat/components/ChatFile.vue';
|
||||
import { buildChatAttachmentUrl } from '@/features/ai/chatHub/chat.api';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
|
@ -38,6 +38,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const clipboard = useClipboard();
|
||||
const chatStore = useChatStore();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const editedText = ref('');
|
||||
|
|
@ -52,8 +53,11 @@ const speech = useSpeechSynthesis(messageContent, {
|
|||
volume: 1,
|
||||
});
|
||||
|
||||
const model = computed(() => unflattenModel(message));
|
||||
const agent = useAgent(model);
|
||||
const agent = computed<ChatModelDto | null>(() => {
|
||||
const model = unflattenModel(message);
|
||||
|
||||
return model ? chatStore.getAgent(model) : null;
|
||||
});
|
||||
|
||||
const attachments = computed(() =>
|
||||
message.attachments.map(({ fileName, mimeType }, index) => ({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { useChatStore } from '@/features/ai/chatHub/chat.store';
|
||||
import { unflattenModel } from '@/features/ai/chatHub/chat.utils';
|
||||
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
|
||||
import ChatSidebarLink from '@/features/ai/chatHub/components/ChatSidebarLink.vue';
|
||||
import { useAgent } from '@/features/ai/chatHub/composables/useAgent';
|
||||
import { CHAT_CONVERSATION_VIEW } from '@/features/ai/chatHub/constants';
|
||||
import { type ChatHubSessionDto } from '@n8n/api-types';
|
||||
import { type ChatModelDto, type ChatHubSessionDto } from '@n8n/api-types';
|
||||
import { N8nInput } from '@n8n/design-system';
|
||||
import type { ActionDropdownItem } from '@n8n/design-system/types';
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||
|
|
@ -24,11 +24,31 @@ const emit = defineEmits<{
|
|||
|
||||
const input = useTemplateRef('input');
|
||||
const editedLabel = ref('');
|
||||
const chatStore = useChatStore();
|
||||
|
||||
type SessionAction = 'rename' | 'delete';
|
||||
|
||||
const model = computed(() => unflattenModel(session));
|
||||
const agent = useAgent(model);
|
||||
const agent = computed<ChatModelDto | null>(() => {
|
||||
const model = unflattenModel(session);
|
||||
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agent = chatStore.getAgent(model);
|
||||
|
||||
if (agent) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
name: session.agentName || '',
|
||||
description: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
});
|
||||
|
||||
const dropdownItems = computed<Array<ActionDropdownItem<SessionAction>>>(() => [
|
||||
{
|
||||
|
|
@ -107,7 +127,7 @@ watch(
|
|||
/>
|
||||
</template>
|
||||
<template #icon>
|
||||
<ChatAgentAvatar :agent="agent ?? null" size="sm" />
|
||||
<ChatAgentAvatar :agent="agent" size="sm" />
|
||||
</template>
|
||||
</ChatSidebarLink>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import { N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system';
|
|||
import Logo from '@n8n/design-system/components/N8nLogo';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useIntersectionObserver } from '@vueuse/core';
|
||||
import ChatSessionMenuItem from './ChatSessionMenuItem.vue';
|
||||
import SkeletonMenuItem from './SkeletonMenuItem.vue';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
|
||||
defineProps<{ isMobileDevice: boolean }>();
|
||||
|
|
@ -28,11 +30,11 @@ const settingsStore = useSettingsStore();
|
|||
const telemetry = useTelemetry();
|
||||
|
||||
const renamingSessionId = ref<string>();
|
||||
const loadMoreTrigger = ref<HTMLElement | null>(null);
|
||||
|
||||
const currentSessionId = computed(() =>
|
||||
typeof route.params.id === 'string' ? route.params.id : undefined,
|
||||
);
|
||||
const readyToShowConversations = computed(() => chatStore.agentsReady && chatStore.sessionsReady);
|
||||
|
||||
const groupedConversations = computed(() => groupConversationsByDate(chatStore.sessions));
|
||||
|
||||
|
|
@ -84,8 +86,20 @@ function handleNewChatClick() {
|
|||
sidebar.toggleOpen(false);
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
loadMoreTrigger,
|
||||
([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
void chatStore.fetchMoreSessions();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void chatStore.fetchSessions();
|
||||
if (!chatStore.sessionsReady) {
|
||||
void chatStore.fetchSessions(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -131,8 +145,18 @@ onMounted(() => {
|
|||
/>
|
||||
</div>
|
||||
<N8nScrollArea as-child type="scroll">
|
||||
<div v-if="readyToShowConversations" :class="$style.items">
|
||||
<div v-for="group in groupedConversations" :key="group.group" :class="$style.group">
|
||||
<div :class="$style.items">
|
||||
<div
|
||||
v-if="groupedConversations.length === 0 && !chatStore.sessionsReady"
|
||||
:class="$style.group"
|
||||
>
|
||||
<SkeletonMenuItem v-for="i in 10" :key="`loading-${i}`" />
|
||||
</div>
|
||||
<div
|
||||
v-for="(group, index) in groupedConversations"
|
||||
:key="group.group"
|
||||
:class="$style.group"
|
||||
>
|
||||
<N8nText :class="$style.groupHeader" size="small" bold color="text-light">
|
||||
{{ group.group }}
|
||||
</N8nText>
|
||||
|
|
@ -147,7 +171,12 @@ onMounted(() => {
|
|||
@confirm-rename="handleConfirmRename"
|
||||
@delete="handleDeleteSession"
|
||||
/>
|
||||
<template v-if="index === groupedConversations.length - 1 && chatStore.sessionsLoading">
|
||||
<SkeletonMenuItem v-for="i in 10" :key="i" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div ref="loadMoreTrigger" :class="$style.loadMoreTrigger"></div>
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
<MainSidebarUserArea :fully-expanded="true" :is-collapsed="false" />
|
||||
|
|
@ -203,6 +232,11 @@ onMounted(() => {
|
|||
padding: 0 var(--spacing--4xs) var(--spacing--3xs) var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.loadMoreTrigger {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: var(--spacing--xs);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div :class="$style.skeletonItem">
|
||||
<div :class="$style.skeletonAvatar"></div>
|
||||
<div :class="$style.skeletonText"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.skeletonItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing--3xs);
|
||||
gap: var(--spacing--3xs);
|
||||
border-radius: var(--spacing--4xs);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.skeletonAvatar,
|
||||
.skeletonText {
|
||||
background: var(--color--foreground);
|
||||
animation: skeleton-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeletonAvatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeletonText {
|
||||
height: 14px;
|
||||
width: 80%;
|
||||
border-radius: var(--radius--sm);
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import type { ChatHubConversationModel, ChatModelDto } from '@n8n/api-types';
|
||||
import { computed, type ComputedRef, type MaybeRef, toValue } from 'vue';
|
||||
import { useChatStore } from '../chat.store';
|
||||
|
||||
export function useAgent(
|
||||
model: MaybeRef<ChatHubConversationModel | null | undefined>,
|
||||
): ComputedRef<ChatModelDto | undefined> {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
return computed(() => {
|
||||
const modelValue = toValue(model);
|
||||
|
||||
return modelValue ? chatStore.getAgent(modelValue) : undefined;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user