fix(editor): Fix outstanding chat UI bugs (no-changelog) (#21413)

This commit is contained in:
Suguru Inoue 2025-10-31 10:56:31 +01:00 committed by GitHub
parent a6e27e1926
commit c46d121bb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 208 additions and 124 deletions

View File

@ -243,5 +243,6 @@ export interface EnrichedStructuredChunk extends StructuredChunk {
messageId: ChatMessageId;
previousMessageId: ChatMessageId | null;
retryOfMessageId: ChatMessageId | null;
executionId: number | null;
};
}

View File

@ -916,6 +916,7 @@ export class ChatHubService {
messageId: message.id,
previousMessageId: message.previousMessageId,
retryOfMessageId: message.retryOfMessageId,
executionId: executionId ? +executionId : null,
},
};

View File

@ -1,10 +1,7 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast';
import { LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL } from '@/constants';
import {
findOneFromModelsResponse,
restoreConversationModelFromMessageOrSession,
} from '@/features/ai/chatHub/chat.utils';
import { findOneFromModelsResponse, unflattenModel } from '@/features/ai/chatHub/chat.utils';
import ChatConversationHeader from '@/features/ai/chatHub/components/ChatConversationHeader.vue';
import ChatMessage from '@/features/ai/chatHub/components/ChatMessage.vue';
import ChatPrompt from '@/features/ai/chatHub/components/ChatPrompt.vue';
@ -29,7 +26,7 @@ import {
import { N8nIconButton, N8nScrollArea } from '@n8n/design-system';
import { useLocalStorage, useMediaQuery, useScroll } from '@vueuse/core';
import { v4 as uuidv4 } from 'uuid';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useChatStore } from './chat.store';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
@ -60,9 +57,12 @@ const currentConversation = computed(() =>
: undefined,
);
const currentConversationTitle = computed(() => currentConversation.value?.title);
const readyToShowMessages = computed(() => chatStore.agentsReady);
const { arrivedState } = useScroll(scrollContainerRef, { throttle: 100, offset: { bottom: 100 } });
const { arrivedState, measure } = useScroll(scrollContainerRef, {
throttle: 100,
offset: { bottom: 100 },
});
const defaultModel = useLocalStorage<ChatHubConversationModel | null>(
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL(usersStore.currentUserId ?? 'anonymous'),
null,
@ -109,13 +109,17 @@ const selectedModel = computed<ChatModelDto | undefined>(() => {
return modelFromQuery.value;
}
if (!currentConversation.value?.provider) {
return defaultModel.value ? chatStore.getAgent(defaultModel.value) : undefined;
if (currentConversation.value?.provider) {
const model = unflattenModel(currentConversation.value);
return model ? chatStore.getAgent(model) : undefined;
}
const model = restoreConversationModelFromMessageOrSession(currentConversation.value);
if (chatStore.streaming?.sessionId === sessionId.value) {
return chatStore.getAgent(chatStore.streaming.model);
}
return model ? chatStore.getAgent(model) : undefined;
return defaultModel.value ? chatStore.getAgent(defaultModel.value) : undefined;
});
const { credentialsByProvider, selectCredential } = useChatCredentials(
@ -170,26 +174,22 @@ function scrollToMessage(messageId: ChatMessageId) {
// Scroll to the bottom when a new message is added
watch(
() => chatMessages.value[chatMessages.value.length - 1]?.id,
(lastMessageId) => {
if (!lastMessageId) {
[readyToShowMessages, () => chatMessages.value[chatMessages.value.length - 1]?.id],
([ready, lastMessageId]) => {
if (!ready || !lastMessageId) {
return;
}
const currentMessage = chatStore.lastMessage(sessionId.value);
if (lastMessageId !== currentMessage?.id) {
scrollToBottom(currentMessage !== null);
return;
}
// Prevent "scroll to bottom" button from appearing when not necessary
void nextTick(measure);
const message = chatStore
.getActiveMessages(sessionId.value)
.find((m) => m.id === lastMessageId);
if (message?.previousMessageId) {
if (chatStore.streaming?.sessionId === sessionId.value) {
// Scroll to user's prompt when the message is being generated
scrollToMessage(message.previousMessageId);
scrollToMessage(chatStore.streaming.promptId);
return;
}
scrollToBottom(false);
},
{ immediate: true, flush: 'post' },
);
@ -286,7 +286,7 @@ function handleCancelEditMessage() {
function handleEditMessage(message: ChatHubMessageDto) {
if (
chatStore.isResponding(message.sessionId) ||
isResponding.value ||
!['human', 'ai'].includes(message.type) ||
!selectedModel.value ||
!credentialsForSelectedProvider.value
@ -308,7 +308,7 @@ function handleEditMessage(message: ChatHubMessageDto) {
function handleRegenerateMessage(message: ChatHubMessageDto) {
if (
chatStore.isResponding(message.sessionId) ||
isResponding.value ||
message.type !== 'ai' ||
!selectedModel.value ||
!credentialsForSelectedProvider.value
@ -400,7 +400,7 @@ function closeAgentEditor() {
/>
<N8nScrollArea
v-if="chatStore.agentsReady"
v-if="readyToShowMessages"
type="scroll"
:enable-vertical-scroll="true"
:enable-horizontal-scroll="false"

View File

@ -36,14 +36,21 @@ import {
type ChatHubMessageStatus,
type ChatModelDto,
} from '@n8n/api-types';
import type { CredentialsMap, ChatMessage, ChatConversation } from './chat.types';
import type {
CredentialsMap,
ChatMessage,
ChatConversation,
ChatStreamingState,
} from './chat.types';
import { retry } from '@n8n/utils/retry';
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
export const useChatStore = defineStore(CHAT_STORE, () => {
const rootStore = useRootStore();
const agents = ref<ChatModelsResponse>();
const sessions = ref<ChatHubSessionDto[]>();
const currentEditingAgent = ref<ChatHubAgentDto | null>(null);
const streaming = ref<ChatStreamingState>();
const conversationsBySession = ref<Map<ChatSessionId, ChatConversation>>(new Map());
@ -159,12 +166,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const a = messagesGraph[first];
const b = messagesGraph[second];
// TODO: Disabled for now, messages retried don't get this at the FE before reload
// TOOD: Do we even need runIndex at all?
// if (a.runIndex !== b.runIndex) {
// return a.runIndex - b.runIndex;
// }
if (a.createdAt !== b.createdAt) {
return a.createdAt < b.createdAt ? -1 : 1;
}
@ -270,7 +271,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const messages = linkMessages(Object.values(conversation.messages));
// TOOD: Do we need 'state' column at all?
const latestMessage = Object.values(messages)
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
.pop();
@ -281,106 +281,107 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
});
}
function onBeginMessage(
sessionId: ChatSessionId,
messageId: ChatMessageId,
previousMessageId: ChatMessageId | null,
retryOfMessageId: ChatMessageId | null,
status: ChatHubMessageStatus = 'running',
) {
addMessage(sessionId, {
id: messageId,
sessionId,
type: 'ai',
name: 'AI',
content: '',
provider: null,
model: null,
workflowId: null,
executionId: null,
agentId: null,
status,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
previousMessageId,
retryOfMessageId,
revisionOfMessageId: null,
responses: [],
alternatives: [],
});
function onBeginMessage() {
if (!streaming.value?.messageId) {
return;
}
const message = createAiMessageFromStreamingState(
streaming.value.sessionId,
streaming.value.messageId,
streaming.value,
);
addMessage(streaming.value.sessionId, message);
if (sessions.value?.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(),
...flattenModel(streaming.value.model),
},
];
}
function ensureMessage(
sessionId: ChatSessionId,
messageId: ChatMessageId,
previousMessageId: ChatMessageId | null,
retryOfMessageId: ChatMessageId | null,
): ChatMessage {
function ensureMessage(sessionId: ChatSessionId, messageId: ChatMessageId): ChatMessage {
const conversation = ensureConversation(sessionId);
const message = conversation.messages[messageId];
if (message) {
return message;
}
return addMessage(sessionId, {
id: messageId,
sessionId,
type: 'ai',
name: 'AI',
content: '',
provider: null,
model: null,
workflowId: null,
executionId: null,
status: 'running',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
previousMessageId,
retryOfMessageId,
revisionOfMessageId: null,
responses: [],
alternatives: [],
agentId: null,
});
const newMessage = createAiMessageFromStreamingState(sessionId, messageId, streaming.value);
return addMessage(sessionId, newMessage);
}
function onChunk(sessionId: string, messageId: string, chunk: string) {
appendMessage(sessionId, messageId, chunk);
function onChunk(chunk: string) {
if (streaming.value?.messageId) {
appendMessage(streaming.value.sessionId, streaming.value.messageId, chunk);
}
}
function onEndMessage(sessionId: ChatSessionId, messageId: ChatMessageId) {
updateMessage(sessionId, messageId, 'success');
function onEndMessage() {
if (streaming.value?.messageId) {
updateMessage(streaming.value.sessionId, streaming.value.messageId, 'success');
}
}
function onStreamMessage(sessionId: string, chunk: EnrichedStructuredChunk) {
const { messageId, previousMessageId, retryOfMessageId } = chunk.metadata;
function onStreamMessage(chunk: EnrichedStructuredChunk) {
if (!streaming.value) {
return;
}
const { sessionId } = streaming.value;
streaming.value = { ...streaming.value, ...chunk.metadata };
switch (chunk.type) {
case 'begin':
onBeginMessage(sessionId, messageId, previousMessageId, retryOfMessageId);
onBeginMessage();
break;
case 'item':
onChunk(sessionId, messageId, chunk.content ?? '');
onChunk(chunk.content ?? '');
break;
case 'end':
onEndMessage(sessionId, messageId);
onEndMessage();
break;
case 'error': {
// Ignore errors after cancellation
const message = ensureMessage(sessionId, messageId, previousMessageId, retryOfMessageId);
const message = ensureMessage(sessionId, chunk.metadata.messageId);
if (message.status === 'cancelled') {
return;
}
updateMessage(sessionId, messageId, 'error');
onChunk(sessionId, messageId, message.content ?? '');
updateMessage(sessionId, chunk.metadata.messageId, 'error');
onChunk(message.content ?? '');
break;
}
}
}
async function onStreamDone(sessionId: ChatSessionId) {
async function onStreamDone() {
if (!streaming.value) {
return;
}
const { sessionId } = streaming.value;
streaming.value = undefined;
// wait up to 3 seconds until conversation title is generated
await retry(
async () => {
@ -396,8 +397,15 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
await fetchSessions();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function onStreamError(sessionId: ChatSessionId, _e: Error) {
function onStreamError() {
if (!streaming.value) {
return;
}
const { sessionId } = streaming.value;
streaming.value = undefined;
const conversation = getConversation(sessionId);
if (!conversation) {
return;
@ -445,6 +453,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
alternatives: [],
});
streaming.value = { promptId: messageId, sessionId, model };
sendMessageApi(
rootStore.restApiContext,
{
@ -455,9 +465,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
credentials,
previousMessageId,
},
(chunk: EnrichedStructuredChunk) => onStreamMessage(sessionId, chunk),
async () => await onStreamDone(sessionId),
(e) => onStreamError(sessionId, e),
onStreamMessage,
onStreamDone,
onStreamError,
);
}
@ -468,7 +478,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
model: ChatHubConversationModel,
credentials: ChatHubSendMessageRequest['credentials'],
) {
const messageId = uuidv4();
const promptId = uuidv4();
const conversation = ensureConversation(sessionId);
const message = conversation.messages[editId];
@ -476,7 +486,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
if (message?.type === 'human') {
addMessage(sessionId, {
id: messageId,
id: promptId,
sessionId,
type: 'human',
name: message.name ?? 'User',
@ -499,19 +509,21 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
replaceMessageContent(sessionId, editId, content);
}
streaming.value = { promptId, sessionId, model };
editMessageApi(
rootStore.restApiContext,
sessionId,
editId,
{
model,
messageId,
messageId: promptId,
message: content,
credentials,
},
(chunk: EnrichedStructuredChunk) => onStreamMessage(sessionId, chunk),
async () => await onStreamDone(sessionId),
(e) => onStreamError(sessionId, e),
onStreamMessage,
onStreamDone,
onStreamError,
);
}
@ -528,6 +540,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
throw new Error('No previous message to base regeneration on');
}
streaming.value = { promptId: retryId, sessionId, model };
regenerateMessageApi(
rootStore.restApiContext,
sessionId,
@ -536,9 +550,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
model,
credentials,
},
(chunk: EnrichedStructuredChunk) => onStreamMessage(sessionId, chunk),
async () => await onStreamDone(sessionId),
(e) => onStreamError(sessionId, e),
onStreamMessage,
onStreamDone,
onStreamError,
);
}
@ -548,6 +562,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
if (currentMessage && currentMessage.status === 'running') {
updateMessage(sessionId, currentMessage.id, 'cancelled');
await stopGenerationApi(rootStore.restApiContext, sessionId, currentMessage.id);
streaming.value = undefined;
}
}
@ -708,6 +723,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
/**
* messaging
*/
streaming,
isResponding,
sendMessage,
editMessage,

View File

@ -4,6 +4,10 @@ import {
type ChatMessageId,
type ChatHubSessionDto,
type ChatHubConversationDto,
type ChatSessionId,
type ChatHubConversationModel,
type EnrichedStructuredChunk,
type ChatHubProvider,
} from '@n8n/api-types';
import { z } from 'zod';
@ -68,3 +72,16 @@ export interface ChatAgentFilter {
provider: 'custom-agent' | 'n8n' | '';
search: string;
}
export interface ChatStreamingState extends Partial<EnrichedStructuredChunk['metadata']> {
promptId: ChatMessageId;
sessionId: ChatSessionId;
model: ChatHubConversationModel;
}
export interface FlattenedModel {
provider: ChatHubProvider | null;
model: string | null;
workflowId: string | null;
agentId: string | null;
}

View File

@ -4,8 +4,16 @@ import {
type ChatModelsResponse,
type ChatHubSessionDto,
type ChatModelDto,
type ChatSessionId,
type ChatMessageId,
} from '@n8n/api-types';
import type { ChatMessage, GroupedConversations, ChatAgentFilter } from './chat.types';
import type {
ChatMessage,
GroupedConversations,
ChatAgentFilter,
ChatStreamingState,
FlattenedModel,
} from './chat.types';
import { CHAT_VIEW } from './constants';
export function findOneFromModelsResponse(response: ChatModelsResponse): ChatModelDto | undefined {
@ -99,9 +107,19 @@ export function getAgentRoute(model: ChatHubConversationModel) {
};
}
export function restoreConversationModelFromMessageOrSession(
messageOrSession: ChatHubSessionDto | ChatMessage,
): ChatHubConversationModel | null {
export function flattenModel(model: ChatHubConversationModel): FlattenedModel {
return {
provider: model.provider,
model:
model?.provider === 'n8n' || model?.provider === 'custom-agent'
? null
: (model?.model ?? null),
workflowId: model?.provider === 'n8n' ? model.workflowId : null,
agentId: model?.provider === 'custom-agent' ? model.agentId : null,
};
}
export function unflattenModel(messageOrSession: FlattenedModel): ChatHubConversationModel | null {
if (messageOrSession.provider === null) {
return null;
}
@ -198,3 +216,34 @@ export function fromStringToModel(value: string): ChatHubConversationModel | und
? { provider: 'custom-agent', agentId: identifier }
: { provider: parsedProvider, model: identifier };
}
export function createAiMessageFromStreamingState(
sessionId: ChatSessionId,
messageId: ChatMessageId,
streaming?: Partial<ChatStreamingState>,
): ChatMessage {
return {
id: messageId,
sessionId,
type: 'ai',
name: 'AI',
content: '',
executionId: streaming?.executionId ?? null,
status: 'running',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
previousMessageId: streaming?.previousMessageId ?? null,
retryOfMessageId: streaming?.retryOfMessageId ?? null,
revisionOfMessageId: null,
responses: [],
alternatives: [],
...(streaming?.model
? flattenModel(streaming.model)
: {
provider: null,
model: null,
workflowId: null,
agentId: null,
}),
};
}

View File

@ -12,7 +12,7 @@ import { computed, nextTick, onBeforeMount, ref, useTemplateRef, watch } from 'v
import VueMarkdown from 'vue-markdown-render';
import type { ChatMessage } from '../chat.types';
import ChatMessageActions from './ChatMessageActions.vue';
import { restoreConversationModelFromMessageOrSession } from '@/features/ai/chatHub/chat.utils';
import { unflattenModel } from '@/features/ai/chatHub/chat.utils';
import { useAgent } from '@/features/ai/chatHub/composables/useAgent';
const { message, compact, isEditing, isStreaming, minHeight } = defineProps<{
@ -48,7 +48,7 @@ const speech = useSpeechSynthesis(messageContent, {
volume: 1,
});
const model = computed(() => restoreConversationModelFromMessageOrSession(message));
const model = computed(() => unflattenModel(message));
const agent = useAgent(model);
async function handleCopy() {

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { restoreConversationModelFromMessageOrSession } from '@/features/ai/chatHub/chat.utils';
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';
@ -27,7 +27,7 @@ const editedLabel = ref('');
type SessionAction = 'rename' | 'delete';
const model = computed(() => restoreConversationModelFromMessageOrSession(session));
const model = computed(() => unflattenModel(session));
const agent = useAgent(model);
const dropdownItems = computed<Array<ActionDropdownItem<SessionAction>>>(() => [