perf: Improve chat UI time-to-interactive (no-changelog) (#22408)

This commit is contained in:
Suguru Inoue
2025-11-28 13:07:36 +01:00
committed by GitHub
parent 2360d8719a
commit c39a4263df
16 changed files with 304 additions and 245 deletions
+8 -5
View File
@@ -274,6 +274,7 @@ export class ChatHubSendMessageRequest extends Z.class({
),
tools: z.array(INodeSchema),
attachments: z.array(chatAttachmentSchema),
agentName: z.string(),
}) {}
export class ChatHubRegenerateMessageRequest extends Z.class({
@@ -301,10 +302,12 @@ export class ChatHubEditMessageRequest extends Z.class({
export class ChatHubUpdateConversationRequest extends Z.class({
title: z.string().optional(),
credentialId: z.string().max(36).optional(),
provider: chatHubProviderSchema.optional(),
model: z.string().max(64).optional(),
workflowId: z.string().max(36).optional(),
agentId: z.string().uuid().optional(),
agent: z
.object({
model: chatHubConversationModelSchema,
name: z.string(),
})
.optional(),
tools: z.array(INodeSchema).optional(),
}) {}
@@ -324,7 +327,7 @@ export interface ChatHubSessionDto {
model: string | null;
workflowId: string | null;
agentId: string | null;
agentName: string | null;
agentName: string;
createdAt: string;
updatedAt: string;
tools: INode[];
@@ -97,10 +97,9 @@ export class ChatHubSession extends WithTimestamps {
@Column({ type: 'varchar', length: 36, nullable: true })
agentId: string | null;
/*
* Cached name of the custom agent to use (if applicable).
* In case agent gets deleted
* Only set when provider is 'custom-agent'.
/**
* Cached display name of the agent/model.
* Used for all providers (LLM providers, custom agents, and n8n workflows).
*/
@Column({ type: 'varchar', length: 128, nullable: true })
agentName: string | null;
@@ -4,7 +4,7 @@ import type { INodeTypeNameVersion } from 'n8n-workflow';
export const CONVERSATION_TITLE_GENERATION_PROMPT = `Generate a concise, descriptive title for this conversation based on the user's message.
Requirements:
- 3 to 5 words
- 2 to 5 words
- Use normal sentence case (not title case)
- No quotation marks
- Only output the title, nothing else
@@ -16,6 +16,7 @@ import {
ChatHubN8nModel,
ChatHubCustomAgentModel,
emptyChatModelsResponse,
type ChatHubUpdateConversationRequest,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { ExecutionRepository, IExecutionResponse, User, WorkflowRepository } from '@n8n/db';
@@ -68,6 +69,7 @@ import { getBase } from '@/workflow-execute-additional-data';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
import type { ChatHubSession } from './chat-hub-session.entity';
@Service()
export class ChatHubService {
@@ -1002,7 +1004,15 @@ export class ChatHubService {
try {
const result = await this.messageRepository.manager.transaction(async (trx) => {
let session = await this.getChatSession(user, sessionId, trx);
session ??= await this.createChatSession(user, sessionId, model, credentialId, tools, trx);
session ??= await this.createChatSession(
user,
sessionId,
model,
credentialId,
tools,
payload.agentName,
trx,
);
await this.ensurePreviousMessage(previousMessageId, sessionId, trx);
const messages = Object.fromEntries((session.messages ?? []).map((m) => [m.id, m]));
@@ -1974,44 +1984,10 @@ export class ChatHubService {
model: ChatHubConversationModel,
credentialId: string | null,
tools: INode[],
agentName: string,
trx?: EntityManager,
) {
let agentName: string | undefined = undefined;
if (model.provider === 'custom-agent') {
// Find the agent to get its name
const agent = await this.chatHubAgentService.getAgentById(model.agentId, user.id);
if (!agent) {
throw new BadRequestError('Agent not found for chat session initialization');
}
agentName = agent.name;
}
if (model.provider === 'n8n') {
// Find the workflow to get its name
const workflow = await this.workflowFinderService.findWorkflowForUser(
model.workflowId,
user,
['workflow:read'],
{ includeTags: false, includeParentFolder: false },
);
if (!workflow) {
throw new BadRequestError('Workflow not found for chat session initialization');
}
const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
if (!chatTrigger) {
throw new BadRequestError(
'Chat trigger not found in workflow for chat session initialization',
);
}
agentName =
typeof chatTrigger.parameters.agentName === 'string' &&
chatTrigger.parameters.agentName.length > 0
? chatTrigger.parameters.agentName
: workflow.name;
}
await this.ensureValidModel(user, model);
return await this.sessionRepository.createChatSession(
{
@@ -2065,7 +2041,7 @@ export class ChatHubService {
model: session.model,
workflowId: session.workflowId,
agentId: session.agentId,
agentName: session.agentName,
agentName: session.agentName ?? '',
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
tools: session.tools,
@@ -2097,7 +2073,7 @@ export class ChatHubService {
model: session.model,
workflowId: session.workflowId,
agentId: session.agentId,
agentName: session.agentName,
agentName: session.agentName ?? '',
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
tools: session.tools,
@@ -2184,15 +2160,7 @@ export class ChatHubService {
async updateSession(
user: User,
sessionId: ChatSessionId,
updates: {
title?: string;
credentialId?: string | null;
provider?: ChatHubProvider;
model?: string | null;
workflowId?: string | null;
agentId?: string | null;
agentName?: string | null;
},
updates: ChatHubUpdateConversationRequest,
) {
const session = await this.sessionRepository.getOneById(sessionId, user.id);
@@ -2200,62 +2168,34 @@ export class ChatHubService {
throw new NotFoundError('Session not found');
}
if (updates.workflowId) {
// Validate the workflow exists and is accessible
const workflow = await this.workflowFinderService.findWorkflowForUser(
updates.workflowId,
user,
['workflow:read'],
{ includeTags: false, includeParentFolder: false },
);
// Prepare the actual updates to be sent to the repository
const sessionUpdates: Partial<ChatHubSession> = {};
if (!workflow) {
throw new BadRequestError('Workflow not found');
if (updates.agent) {
const model = updates.agent.model;
await this.ensureValidModel(user, model);
sessionUpdates.agentName = updates.agent.name;
sessionUpdates.provider = model.provider;
sessionUpdates.model = null;
sessionUpdates.credentialId = null;
sessionUpdates.agentId = null;
sessionUpdates.workflowId = null;
if (updates.agent.model.provider === 'n8n') {
sessionUpdates.workflowId = updates.agent.model.workflowId;
} else if (updates.agent.model.provider === 'custom-agent') {
sessionUpdates.agentId = updates.agent.model.agentId;
} else {
sessionUpdates.model = updates.agent.model.model;
}
const chatTriggers = workflow.nodes.filter((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
if (chatTriggers.length !== 1) {
throw new BadRequestError('Workflow must have exactly one chat trigger');
}
const chatTrigger = chatTriggers[0];
updates.agentName =
typeof chatTrigger.parameters.agentName === 'string' &&
chatTrigger.parameters.agentName.length > 0
? chatTrigger.parameters.agentName
: workflow.name;
}
if (updates.agentId) {
// Validate the agent exists and is accessible
const agent = await this.chatHubAgentService.getAgentById(updates.agentId, user.id);
if (updates.title !== undefined) sessionUpdates.title = updates.title;
if (updates.credentialId !== undefined) sessionUpdates.credentialId = updates.credentialId;
if (!agent) {
throw new BadRequestError('Agent not found');
}
updates.agentName = agent.name;
}
if (updates.provider === 'n8n') {
// n8n provider only stores workflowId
updates.model = null;
updates.credentialId = null;
updates.agentId = null;
} else if (updates.provider === 'custom-agent') {
// custom-agent provider only stores agentId & Agent name
updates.model = null;
updates.credentialId = null;
updates.workflowId = null;
} else if (updates.provider) {
updates.workflowId = null;
updates.agentId = null;
updates.agentName = null;
}
return await this.sessionRepository.updateChatSession(sessionId, updates);
return await this.sessionRepository.updateChatSession(sessionId, sessionUpdates);
}
/**
@@ -2271,4 +2211,35 @@ export class ChatHubService {
await this.chatHubAttachmentService.deleteAllBySessionId(sessionId);
await this.sessionRepository.deleteChatHubSession(sessionId);
}
private async ensureValidModel(user: User, model: ChatHubConversationModel) {
if (model.provider === 'custom-agent') {
// Find the agent to get its name
const agent = await this.chatHubAgentService.getAgentById(model.agentId, user.id);
if (!agent) {
throw new BadRequestError('Agent not found for chat session initialization');
}
}
if (model.provider === 'n8n') {
// Find the workflow to get its name
const workflow = await this.workflowFinderService.findWorkflowForUser(
model.workflowId,
user,
['workflow:read'],
{ includeTags: false, includeParentFolder: false },
);
if (!workflow) {
throw new BadRequestError('Workflow not found for chat session initialization');
}
const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
if (!chatTrigger) {
throw new BadRequestError(
'Chat trigger not found in workflow for chat session initialization',
);
}
}
}
}
@@ -30,6 +30,7 @@ export interface HumanMessagePayload extends BaseMessagePayload {
previousMessageId: ChatMessageId | null;
attachments: ChatAttachment[];
tools: INode[];
agentName: string;
}
export interface RegenerateMessagePayload extends BaseMessagePayload {
retryId: ChatMessageId;
@@ -22,7 +22,6 @@ import {
} from '@/features/ai/chatHub/constants';
import { useUsersStore } from '@/features/settings/users/users.store';
import {
chatHubConversationModelSchema,
type ChatHubLLMProvider,
PROVIDER_CREDENTIAL_TYPE_MAP,
type ChatHubConversationModel,
@@ -43,6 +42,10 @@ import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCre
import ChatLayout from '@/features/ai/chatHub/components/ChatLayout.vue';
import { INodesSchema, type INode } from 'n8n-workflow';
import { useFileDrop } from '@/features/ai/chatHub/composables/useFileDrop';
import {
type ChatHubConversationModelWithCachedDisplayName,
chatHubConversationModelWithCachedDisplayNameSchema,
} from '@/features/ai/chatHub/chat.types';
import { useI18n } from '@n8n/i18n';
const router = useRouter();
@@ -65,12 +68,9 @@ const isNewSession = computed(() => sessionId.value !== route.params.id);
const scrollableRef = useTemplateRef('scrollable');
const scrollContainerRef = computed(() => scrollableRef.value?.parentElement ?? null);
const currentConversation = computed(() =>
sessionId.value
? chatStore.sessions.find((session) => session.id === sessionId.value)
: undefined,
sessionId.value ? chatStore.sessions.byId[sessionId.value] : undefined,
);
const currentConversationTitle = computed(() => currentConversation.value?.title);
const readyToShowMessages = computed(() => chatStore.agentsReady);
// TODO: This also depends on the model, not all base LLM models support tools.
const canSelectTools = computed(() => isLlmProvider(selectedModel.value?.model.provider));
@@ -80,7 +80,7 @@ const { arrivedState, measure } = useScroll(scrollContainerRef, {
offset: { bottom: 100 },
});
const defaultModel = useLocalStorage<ChatHubConversationModel | null>(
const defaultModel = useLocalStorage<ChatHubConversationModelWithCachedDisplayName | null>(
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL(usersStore.currentUserId ?? 'anonymous'),
null,
{
@@ -89,7 +89,7 @@ const defaultModel = useLocalStorage<ChatHubConversationModel | null>(
serializer: {
read: (value) => {
try {
return chatHubConversationModelSchema.parse(JSON.parse(value));
return chatHubConversationModelWithCachedDisplayNameSchema.parse(JSON.parse(value));
} catch (error) {
return null;
}
@@ -99,6 +99,10 @@ const defaultModel = useLocalStorage<ChatHubConversationModel | null>(
},
);
const defaultModelName = computed(() =>
defaultModel.value ? chatStore.getAgent(defaultModel.value).name : undefined,
);
const defaultTools = useLocalStorage<INode[] | null>(
LOCAL_STORAGE_CHAT_HUB_SELECTED_TOOLS(usersStore.currentUserId ?? 'anonymous'),
null,
@@ -154,25 +158,32 @@ const modelFromQuery = computed<ChatModelDto | null>(() => {
});
const selectedModel = computed<ChatModelDto | null>(() => {
if (!chatStore.agentsReady) {
return null;
if (!isNewSession.value) {
const model = currentConversation.value ? unflattenModel(currentConversation.value) : null;
if (!model) {
return null;
}
return chatStore.getAgent(
model,
(currentConversation.value?.agentName || currentConversation.value?.model) ?? undefined,
);
}
if (modelFromQuery.value) {
return modelFromQuery.value;
}
if (currentConversation.value?.provider) {
const model = unflattenModel(currentConversation.value);
return model ? chatStore.getAgent(model) : null;
}
if (chatStore.streaming?.sessionId === sessionId.value) {
return chatStore.getAgent(chatStore.streaming.model);
return chatStore.getAgent(chatStore.streaming.model, chatStore.streaming.agentName);
}
return defaultModel.value ? chatStore.getAgent(defaultModel.value) : null;
if (!defaultModel.value) {
return null;
}
return chatStore.getAgent(defaultModel.value, defaultModel.value.cachedDisplayName);
});
const { credentialsByProvider, selectCredential } = useChatCredentials(
@@ -207,6 +218,21 @@ const credentialsForSelectedProvider = computed<ChatHubSendMessageRequest['crede
},
);
const isMissingSelectedCredential = computed(() => !credentialsForSelectedProvider.value);
const issue = computed<null | 'missingCredentials' | 'missingAgent'>(() => {
if (!chatStore.agentsReady) {
return null;
}
if (!selectedModel.value) {
return 'missingAgent';
}
if (isMissingSelectedCredential.value) {
return 'missingCredentials';
}
return null;
});
const editingMessageId = ref<string>();
const didSubmitInCurrentSession = ref(false);
@@ -235,9 +261,9 @@ function scrollToMessage(messageId: ChatMessageId) {
// Scroll to the bottom when a new message is added
watch(
[readyToShowMessages, () => chatMessages.value[chatMessages.value.length - 1]?.id],
([ready, lastMessageId]) => {
if (!ready || !lastMessageId) {
() => chatMessages.value[chatMessages.value.length - 1]?.id,
(lastMessageId) => {
if (!lastMessageId) {
return;
}
@@ -271,7 +297,7 @@ watch(
const model = findOneFromModelsResponse(models) ?? null;
if (model) {
void handleSelectModel(model);
void handleSelectAgent(model);
}
},
{ immediate: true },
@@ -322,6 +348,17 @@ watch(
{ immediate: true },
);
// Keep cached display name up-to-date
watch(
defaultModelName,
(name) => {
if (defaultModel.value && name) {
defaultModel.value = { ...defaultModel.value, cachedDisplayName: name };
}
},
{ immediate: true },
);
function onSubmit(message: string, attachments: File[]) {
if (
!message.trim() ||
@@ -342,6 +379,7 @@ function onSubmit(message: string, attachments: File[]) {
credentialsForSelectedProvider.value,
canSelectTools.value ? selectedTools.value : [],
attachments,
selectedModel.value.name,
);
inputRef.value?.setText('');
@@ -406,18 +444,24 @@ function handleRegenerateMessage(message: ChatHubMessageDto) {
);
}
async function handleSelectModel(selection: ChatModelDto) {
async function handleSelectModel(selection: ChatHubConversationModel, displayName?: string) {
const agentName = displayName ?? chatStore.getAgent(selection)?.name ?? '';
if (currentConversation.value) {
try {
await chatStore.updateSessionModel(sessionId.value, selection.model);
await chatStore.updateSessionModel(sessionId.value, selection, agentName);
} catch (error) {
toast.showError(error, 'Could not update selected model');
}
} else {
defaultModel.value = selection.model;
defaultModel.value = { ...selection, cachedDisplayName: agentName };
}
}
async function handleSelectAgent(selection: ChatModelDto) {
await handleSelectModel(selection.model, selection.name);
}
function handleSwitchAlternative(messageId: string) {
shouldSkipNextScrollTrigger.value = true;
chatStore.switchAlternative(sessionId.value, messageId);
@@ -501,8 +545,8 @@ function onFilesDropped(files: File[]) {
ref="headerRef"
:selected-model="selectedModel"
:credentials="credentialsByProvider"
:ready-to-show-model-selector="chatStore.agentsReady"
@select-model="handleSelectModel"
:ready-to-show-model-selector="isNewSession || !!currentConversation"
@select-model="handleSelectAgent"
@edit-custom-agent="handleEditAgent"
@create-custom-agent="openNewAgentCreator"
@select-credential="selectCredential"
@@ -510,7 +554,6 @@ function onFilesDropped(files: File[]) {
/>
<N8nScrollArea
v-if="readyToShowMessages"
type="scroll"
:enable-vertical-scroll="true"
:enable-horizontal-scroll="false"
@@ -532,6 +575,7 @@ function onFilesDropped(files: File[]) {
:compact="isMobileDevice"
:is-editing="editingMessageId === message.id"
:is-streaming="message.status === 'running'"
:cached-agent-display-name="selectedModel?.name ?? null"
:min-height="
didSubmitInCurrentSession &&
message.type === 'ai' &&
@@ -567,6 +611,7 @@ function onFilesDropped(files: File[]) {
:is-tools-selectable="canSelectTools"
:is-missing-credentials="isMissingSelectedCredential"
:is-new-session="isNewSession"
:issue="issue"
@submit="onSubmit"
@stop="onStop"
@select-model="handleConfigureModel"
@@ -38,7 +38,6 @@ import {
type EnrichedStructuredChunk,
type ChatHubMessageStatus,
type ChatModelDto,
type ChatHubConversationsResponse,
} from '@n8n/api-types';
import type {
CredentialsMap,
@@ -66,7 +65,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const telemetry = useTelemetry();
const agents = ref<ChatModelsResponse>();
const sessions = ref<ChatHubConversationsResponse>();
const sessions = ref<{
byId: Partial<Record<string, ChatHubSessionDto>>;
ids: string[] | null;
hasMore: boolean;
nextCursor: string | null;
}>({ byId: {}, ids: null, hasMore: false, nextCursor: null });
const sessionsLoadingMore = ref(false);
const streaming = ref<ChatStreamingState>();
@@ -291,7 +295,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
return;
}
if (!reset && sessions.value && !sessions.value.hasMore && sessions.value.data.length > 0) {
if (
!reset &&
sessions.value &&
!sessions.value.hasMore &&
(sessions.value.ids ?? []).length > 0
) {
return;
}
@@ -306,10 +315,17 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
new Promise((resolve) => setTimeout(resolve, 500)),
]);
sessions.value = {
...response,
data: [...(reset ? [] : (sessions.value?.data ?? [])), ...response.data],
};
if (reset || sessions.value.ids === null) {
sessions.value.ids = [];
}
sessions.value.hasMore = response.hasMore;
sessions.value.nextCursor = response.nextCursor;
for (const session of response.data) {
sessions.value.ids.push(session.id);
sessions.value.byId[session.id] = session;
}
} finally {
sessionsLoadingMore.value = false;
}
@@ -322,7 +338,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
async function fetchMessages(sessionId: string) {
const { conversation } = await fetchMessagesApi(rootStore.restApiContext, sessionId);
const { conversation, session } = await fetchMessagesApi(rootStore.restApiContext, sessionId);
const messages = linkMessages(Object.values(conversation.messages));
@@ -334,6 +350,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
messages,
activeMessageChain: computeActiveChain(messages, latestMessage?.id ?? null),
});
sessions.value.byId[sessionId] = session;
}
function onBeginMessage() {
@@ -348,17 +365,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
);
addMessage(streaming.value.sessionId, message);
if (sessions.value?.data.some((session) => session.id === streaming.value?.sessionId)) {
return;
}
sessions.value = {
hasMore: false,
nextCursor: null,
...sessions.value,
data: [...(sessions.value?.data ?? []), createSessionFromStreamingState(streaming.value)],
};
}
function ensureMessage(sessionId: ChatSessionId, messageId: ChatMessageId): ChatMessage {
@@ -475,6 +481,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
credentials: ChatHubSendMessageRequest['credentials'],
tools: INode[],
files: File[] = [],
agentName: string,
) {
const messageId = uuidv4();
const conversation = ensureConversation(sessionId);
@@ -517,8 +524,17 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
model,
retryOfMessageId: null,
tools,
agentName,
};
if (!sessions.value.byId[sessionId]) {
sessions.value.byId[sessionId] = createSessionFromStreamingState(streaming.value);
if (!sessions.value.ids) {
sessions.value.ids = [];
}
sessions.value.ids.unshift(sessionId);
}
sendMessageApi(
rootStore.restApiContext,
{
@@ -530,6 +546,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
previousMessageId,
tools,
attachments,
agentName,
},
onStreamMessage,
onStreamDone,
@@ -588,6 +605,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
model,
retryOfMessageId: null,
tools: [],
agentName: sessions.value.byId[sessionId]?.agentName ?? '',
};
editMessageApi(
@@ -625,6 +643,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
model,
retryOfMessageId: retryId,
tools: [],
agentName: sessions.value.byId[sessionId]?.agentName ?? '',
};
regenerateMessageApi(
@@ -652,22 +671,18 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
function updateSession(sessionId: ChatSessionId, toUpdate: Partial<ChatHubSessionDto>) {
if (!sessions.value) {
return;
}
const session = sessions.value.byId[sessionId];
sessions.value.data = sessions.value.data?.map((session) =>
session.id === sessionId
? {
...session,
...toUpdate,
}
: session,
);
if (session) {
sessions.value.byId[sessionId] = {
...session,
...toUpdate,
};
}
}
async function updateToolsInSession(sessionId: ChatSessionId, tools: INode[]) {
const session = sessions.value?.data?.find((s) => s.id === sessionId);
const session = sessions.value?.byId[sessionId];
if (!session) {
throw new Error(`Session with ID ${sessionId} not found`);
}
@@ -685,20 +700,22 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
updateSession(sessionId, updated.session);
}
async function updateSessionModel(sessionId: ChatSessionId, model: ChatHubConversationModel) {
await updateConversationApi(rootStore.restApiContext, sessionId, model);
updateSession(sessionId, model);
async function updateSessionModel(
sessionId: ChatSessionId,
model: ChatHubConversationModel,
agentName: string,
) {
await updateConversationApi(rootStore.restApiContext, sessionId, {
agent: { model, name: agentName },
});
updateSession(sessionId, { ...model, agentName });
}
async function deleteSession(sessionId: ChatSessionId) {
await deleteConversationApi(rootStore.restApiContext, sessionId);
if (sessions.value) {
sessions.value = {
...sessions.value,
data: sessions.value.data?.filter((session) => session.id !== sessionId),
};
}
delete sessions.value.byId[sessionId];
sessions.value.ids = sessions.value.ids?.filter((id) => id !== sessionId) ?? null;
}
function switchAlternative(sessionId: ChatSessionId, messageId: ChatMessageId) {
@@ -781,33 +798,23 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
await fetchAgents(credentials);
}
function getAgent(model: ChatHubConversationModel) {
if (!agents.value) return null;
const agent = agents.value[model.provider]?.models.find((agent) =>
function getAgent(model: ChatHubConversationModel, fallbackName: string = ''): ChatModelDto {
const agent = agents.value?.[model.provider]?.models.find((agent) =>
isMatchedAgent(agent, model),
);
if (!agent) {
if (!isLlmProviderModel(model)) {
return null;
}
// Allow custom models chosen by ID even if they are not in the fetched list
return {
model: {
provider: model.provider,
model: model.model,
},
name: model.model,
description: null,
createdAt: null,
updatedAt: null,
allowFileUploads: true,
};
if (agent) {
return agent;
}
return agent;
return {
model,
name: fallbackName,
description: null,
createdAt: null,
updatedAt: null,
allowFileUploads: true,
};
}
async function fetchAllChatSettings() {
@@ -862,8 +869,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
/**
* conversations
*/
sessions: computed(() => sessions.value?.data ?? []),
sessionsReady: computed(() => sessions.value !== undefined),
sessions,
sessionsReady: computed(() => sessions.value.ids !== null),
sessionsLoading: computed(() => sessionsLoadingMore.value),
fetchSessions,
fetchMoreSessions,
@@ -8,9 +8,11 @@ import {
type ChatHubConversationModel,
type EnrichedStructuredChunk,
type ChatHubProvider,
chatHubConversationModelSchema,
} from '@n8n/api-types';
import type { INode } from 'n8n-workflow';
import { z } from 'zod';
import { isLlmProviderModel } from './chat.utils';
export interface UserMessage {
id: string;
@@ -80,6 +82,7 @@ export interface ChatStreamingState extends Partial<EnrichedStructuredChunk['met
model: ChatHubConversationModel;
retryOfMessageId: ChatMessageId | null;
tools: INode[];
agentName: string;
}
export interface FlattenedModel {
@@ -88,3 +91,14 @@ export interface FlattenedModel {
workflowId: string | null;
agentId: string | null;
}
export const chatHubConversationModelWithCachedDisplayNameSchema = chatHubConversationModelSchema
.and(z.object({ cachedDisplayName: z.string().optional() }))
.transform((value) => ({
...value,
cachedDisplayName: value.cachedDisplayName || (isLlmProviderModel(value) ? value.model : ''),
}));
export type ChatHubConversationModelWithCachedDisplayName = z.infer<
typeof chatHubConversationModelWithCachedDisplayNameSchema
>;
@@ -340,7 +340,7 @@ export function createSessionFromStreamingState(streaming: ChatStreamingState):
ownerId: '',
lastMessageAt: new Date().toISOString(),
credentialId: null,
agentName: null,
agentName: streaming.agentName,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tools: streaming.tools,
@@ -46,7 +46,7 @@ const credentialIdForSelectedModelProvider = computed(
() => selectedModel.value && agentMergedCredentials.value[selectedModel.value.provider],
);
const selectedAgent = computed(
() => selectedModel.value && chatStore.getAgent(selectedModel.value),
() => selectedModel.value && chatStore.getAgent(selectedModel.value, selectedModel.value.model),
);
const isEditMode = computed(() => !!props.data.agentId);
@@ -270,6 +270,7 @@ function onSelectTools(newTools: INode[]) {
:include-custom-agents="false"
:credentials="agentMergedCredentials"
:disabled="isLoadingAgent"
warn-missing-credentials
@change="onModelChange"
@select-credential="onCredentialSelected"
/>
@@ -1,13 +1,18 @@
<script setup lang="ts">
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { type ChatModelDto, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
import { N8nAvatar, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { computed } from 'vue';
defineProps<{
agent: ChatModelDto | null;
size: 'sm' | 'md' | 'lg';
tooltip?: boolean;
}>();
const credentialsStore = useCredentialsStore();
const isCredentialsIconReady = computed(() => credentialsStore.allCredentialTypes.length > 0);
</script>
<template>
@@ -26,6 +31,7 @@ defineProps<{
/>
<CredentialIcon
v-else
:class="[$style.credentialsIcon, { [$style.isReady]: isCredentialsIconReady }]"
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[agent.model.provider]"
:size="size === 'sm' ? 16 : size === 'lg' ? 40 : 20"
/>
@@ -36,4 +42,12 @@ defineProps<{
.avatar.md {
transform: scale(1.2);
}
.credentialsIcon {
visibility: hidden;
&.isReady {
visibility: visible;
}
}
</style>
@@ -20,16 +20,18 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useI18n } from '@n8n/i18n';
const { message, compact, isEditing, isStreaming, minHeight } = defineProps<{
message: ChatMessage;
compact: boolean;
isEditing: boolean;
isStreaming: boolean;
/**
* minHeight allows scrolling agent's response to the top while it is being generated
*/
minHeight?: number;
}>();
const { message, compact, isEditing, isStreaming, minHeight, cachedAgentDisplayName } =
defineProps<{
message: ChatMessage;
compact: boolean;
isEditing: boolean;
isStreaming: boolean;
cachedAgentDisplayName: string | null;
/**
* minHeight allows scrolling agent's response to the top while it is being generated
*/
minHeight?: number;
}>();
const emit = defineEmits<{
startEdit: [];
@@ -60,7 +62,11 @@ const speech = useSpeechSynthesis(messageContent, {
const agent = computed<ChatModelDto | null>(() => {
const model = unflattenModel(message);
return model ? chatStore.getAgent(model) : null;
if (!model) {
return null;
}
return chatStore.getAgent(model, cachedAgentDisplayName ?? undefined);
});
const attachments = computed(() =>
@@ -12,11 +12,11 @@ import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils';
import { useI18n } from '@n8n/i18n';
import { I18nT } from 'vue-i18n';
const { selectedModel, selectedTools, isMissingCredentials } = defineProps<{
const { selectedModel, selectedTools, issue } = defineProps<{
isResponding: boolean;
isNewSession: boolean;
isToolsSelectable: boolean;
isMissingCredentials: boolean;
issue: 'missingCredentials' | 'missingAgent' | null;
selectedModel: ChatModelDto | null;
selectedTools: INode[] | null;
}>();
@@ -179,7 +179,7 @@ defineExpose({
<template>
<form :class="$style.prompt" @submit.prevent="handleSubmitForm">
<div :class="$style.inputWrap">
<N8nText v-if="!selectedModel" :class="$style.callout">
<N8nText v-if="issue === 'missingAgent'" :class="$style.callout">
<I18nT
:keypath="
isNewSession
@@ -200,7 +200,7 @@ defineExpose({
</template>
</I18nT>
</N8nText>
<N8nText v-else-if="isMissingCredentials && llmProvider" :class="$style.callout">
<N8nText v-else-if="issue === 'missingCredentials' && llmProvider" :class="$style.callout">
<I18nT
:keypath="
isNewSession
@@ -252,7 +252,7 @@ defineExpose({
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="isMissingCredentials || !selectedModel"
:disabled="!!issue"
@keydown="handleKeydownTextarea"
/>
@@ -261,7 +261,7 @@ defineExpose({
<ToolsSelector
:class="$style.toolsButton"
:selected="selectedTools ?? []"
:disabled="isMissingCredentials || !selectedModel || isResponding"
:disabled="!!issue || isResponding"
transparent-bg
@select="onSelectTools"
/>
@@ -272,7 +272,7 @@ defineExpose({
native-type="button"
type="secondary"
:title="i18n.baseText('chatHub.chat.prompt.button.attach')"
:disabled="isMissingCredentials || isResponding"
:disabled="!!issue || isResponding"
icon="paperclip"
icon-size="large"
text
@@ -287,7 +287,7 @@ defineExpose({
: i18n.baseText('chatHub.chat.prompt.button.voiceInput')
"
type="secondary"
:disabled="isMissingCredentials || !selectedModel || isResponding"
:disabled="!!issue || isResponding"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
@@ -296,7 +296,7 @@ defineExpose({
<N8nIconButton
v-if="!isResponding"
native-type="submit"
:disabled="isMissingCredentials || !selectedModel || !message.trim()"
:disabled="!!issue || !message.trim()"
:title="i18n.baseText('chatHub.chat.prompt.button.send')"
icon="arrow-up"
icon-size="large"
@@ -37,19 +37,7 @@ const agent = computed<ChatModelDto | null>(() => {
return null;
}
const agent = chatStore.getAgent(model);
if (agent) {
return agent;
}
return {
model,
name: session.agentName || '',
description: null,
createdAt: null,
updatedAt: null,
};
return chatStore.getAgent(model, session.agentName);
});
const dropdownItems = computed<Array<ActionDropdownItem<SessionAction>>>(() => [
@@ -17,6 +17,7 @@ import { useIntersectionObserver } from '@vueuse/core';
import ChatSessionMenuItem from './ChatSessionMenuItem.vue';
import SkeletonMenuItem from './SkeletonMenuItem.vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { type ChatHubSessionDto } from '@n8n/api-types';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useI18n } from '@n8n/i18n';
@@ -43,7 +44,19 @@ const currentSessionId = computed(() =>
typeof route.params.id === 'string' ? route.params.id : undefined,
);
const groupedConversations = computed(() => groupConversationsByDate(chatStore.sessions));
const groupedConversations = computed(() =>
groupConversationsByDate(
(chatStore.sessions.ids ?? []).reduce<ChatHubSessionDto[]>((acc, id) => {
const session = chatStore.sessions.byId[id];
if (session) {
acc.push(session);
}
return acc;
}, []),
),
);
function handleStartRename(sessionId: string) {
renamingSessionId.value = sessionId;
@@ -37,6 +37,7 @@ import { fetchChatModelsApi } from '@/features/ai/chatHub/chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useSettingsStore } from '@/app/stores/settings.store';
import { truncateBeforeLast } from '@n8n/utils';
const NEW_AGENT_MENU_ID = 'agent::new';
@@ -45,11 +46,13 @@ const {
includeCustomAgents = true,
credentials,
text,
warnMissingCredentials = false,
} = defineProps<{
selectedAgent: ChatModelDto | null;
includeCustomAgents?: boolean;
credentials: CredentialsMap | null;
text?: boolean;
warnMissingCredentials?: boolean;
}>();
const emit = defineEmits<{
@@ -92,6 +95,7 @@ const credentialsName = computed(() =>
const isCredentialsRequired = computed(() => isLlmProviderModel(selectedAgent?.model));
const isCredentialsMissing = computed(
() =>
warnMissingCredentials &&
isCredentialsRequired.value &&
selectedAgent?.model.provider &&
!credentials?.[selectedAgent?.model.provider],
@@ -343,10 +347,10 @@ defineExpose({
/>
<div :class="$style.selected">
<div>
{{ selectedLabel }}
{{ truncateBeforeLast(selectedLabel, 30) }}
</div>
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
{{ credentialsName }}
{{ truncateBeforeLast(credentialsName, 30) }}
</N8nText>
<N8nText v-else-if="isCredentialsMissing" size="xsmall" color="danger">
<N8nIcon
@@ -382,13 +386,6 @@ defineExpose({
flex-direction: column;
align-items: start;
gap: var(--spacing--4xs);
max-width: 200px;
& > div {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
}
.icon {