fix: Chat UI feedback (no-changelog) (#22010)

This commit is contained in:
Suguru Inoue 2025-11-19 09:57:32 +01:00 committed by GitHub
parent fe297d09ab
commit fe871058e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 228 additions and 70 deletions

View File

@ -116,6 +116,7 @@ const defaultTools = useLocalStorage<INode[] | null>(
);
const toolsSelection = ref<INode[] | null>(null);
const shouldSkipNextScrollTrigger = ref(false);
const selectedTools = computed<INode[]>(() => {
if (currentConversation.value?.tools) {
@ -228,6 +229,11 @@ watch(
return;
}
if (shouldSkipNextScrollTrigger.value) {
shouldSkipNextScrollTrigger.value = false;
return;
}
// Prevent "scroll to bottom" button from appearing when not necessary
void nextTick(measure);
@ -376,7 +382,7 @@ function handleRegenerateMessage(message: ChatHubMessageDto) {
return;
}
const messageToRetry = message.retryOfMessageId ?? message.id;
const messageToRetry = message.id;
chatStore.regenerateMessage(
sessionId.value,
@ -399,6 +405,7 @@ async function handleSelectModel(selection: ChatModelDto) {
}
function handleSwitchAlternative(messageId: string) {
shouldSkipNextScrollTrigger.value = true;
chatStore.switchAlternative(sessionId.value, messageId);
}

View File

@ -43,7 +43,7 @@ import type {
ChatStreamingState,
} from './chat.types';
import { retry } from '@n8n/utils/retry';
import { isMatchedAgent } from './chat.utils';
import { buildUiMessages, isMatchedAgent } from './chat.utils';
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { type INode } from 'n8n-workflow';
@ -65,7 +65,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const conversation = getConversation(sessionId);
if (!conversation) return [];
return conversation.activeMessageChain.map((id) => conversation.messages[id]).filter(Boolean);
return buildUiMessages(sessionId, conversation, streaming.value);
};
function ensureConversation(sessionId: ChatSessionId): ChatConversation {
@ -459,7 +459,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
alternatives: [],
});
streaming.value = { promptId: messageId, sessionId, model };
streaming.value = {
promptId: messageId,
sessionId,
model,
retryOfMessageId: null,
};
sendMessageApi(
rootStore.restApiContext,
@ -522,7 +527,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
replaceMessageContent(sessionId, editId, content);
}
streaming.value = { promptId, sessionId, model };
streaming.value = {
promptId,
sessionId,
model,
retryOfMessageId: null,
};
editMessageApi(
rootStore.restApiContext,
@ -553,7 +563,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
throw new Error('No previous message to base regeneration on');
}
streaming.value = { promptId: retryId, sessionId, model };
streaming.value = {
promptId: retryId,
sessionId,
model,
retryOfMessageId: retryId,
};
regenerateMessageApi(
rootStore.restApiContext,

View File

@ -77,6 +77,7 @@ export interface ChatStreamingState extends Partial<EnrichedStructuredChunk['met
promptId: ChatMessageId;
sessionId: ChatSessionId;
model: ChatHubConversationModel;
retryOfMessageId: ChatMessageId | null;
}
export interface FlattenedModel {

View File

@ -13,8 +13,10 @@ import type {
ChatAgentFilter,
ChatStreamingState,
FlattenedModel,
ChatConversation,
} from './chat.types';
import { CHAT_VIEW } from './constants';
import { v4 as uuidv4 } from 'uuid';
export function findOneFromModelsResponse(response: ChatModelsResponse): ChatModelDto | undefined {
for (const provider of chatHubProviderSchema.options) {
@ -259,3 +261,61 @@ export function createAiMessageFromStreamingState(
}),
};
}
export function buildUiMessages(
sessionId: string,
conversation: ChatConversation,
streaming?: ChatStreamingState,
): ChatMessage[] {
const messagesToShow: ChatMessage[] = [];
let foundRunning = false;
for (let index = 0; index < conversation.activeMessageChain.length; index++) {
const id = conversation.activeMessageChain[index];
const message = conversation.messages[id];
if (!message) {
continue;
}
foundRunning = foundRunning || message.status === 'running';
if (foundRunning || streaming?.sessionId !== sessionId || message.type !== 'ai') {
messagesToShow.push(message);
continue;
}
if (streaming.retryOfMessageId === id && !streaming.messageId) {
// While waiting for streaming to start on regeneration, show previously generated message
// in running state as an immediate feedback
messagesToShow.push({ ...message, content: '', status: 'running' });
foundRunning = true;
continue;
}
if (index === conversation.activeMessageChain.length - 1) {
// When agent responds multiple messages (e.g. when tools are used),
// there's a noticeable time gap between messages.
// In order to indicate that agent is still responding, show the last AI message as running
messagesToShow.push({ ...message, status: 'running' });
foundRunning = true;
continue;
}
messagesToShow.push(message);
}
if (
!foundRunning &&
streaming?.sessionId === sessionId &&
!streaming.messageId &&
streaming.retryOfMessageId === null &&
streaming.promptId === messagesToShow[messagesToShow.length - 1]?.id
) {
// While waiting for streaming to start on sending new message/editing, append a fake message
// in running state as an immediate feedback
messagesToShow.push(createAiMessageFromStreamingState(sessionId, uuidv4(), streaming));
}
return messagesToShow;
}

View File

@ -20,6 +20,7 @@ defineProps<{
/>
<N8nAvatar
v-else-if="agent.model.provider === 'custom-agent' || agent.model.provider === 'n8n'"
:class="[$style.avatar, $style[size]]"
:first-name="agent.name"
:size="size === 'lg' ? 'medium' : size === 'sm' ? 'xxsmall' : 'xsmall'"
/>
@ -30,3 +31,9 @@ defineProps<{
/>
</N8nTooltip>
</template>
<style lang="scss" module>
.avatar.md {
transform: scale(1.2);
}
</style>

View File

@ -1,8 +1,28 @@
<script setup lang="ts">
import { MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants';
import { useMediaQuery } from '@vueuse/core';
import { CHAT_VIEW, MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants';
import { useMediaQuery, useEventListener } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { useUIStore } from '@/app/stores/ui.store';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const router = useRouter();
const uiStore = useUIStore();
const { isCtrlKeyPressed } = useDeviceSupport();
// Cmd+Shift+O to start new chat
useEventListener(document, 'keydown', (event: KeyboardEvent) => {
if (
event.key.toLowerCase() === 'o' &&
isCtrlKeyPressed(event) &&
event.shiftKey &&
!uiStore.isAnyModalOpen
) {
event.preventDefault();
event.stopPropagation();
void router.push({ name: CHAT_VIEW, force: true });
}
});
</script>
<template>

View File

@ -162,7 +162,7 @@ onBeforeMount(() => {
</div>
</div>
<template v-else>
<div :class="$style.chatMessage">
<div :class="[$style.chatMessage, { [$style.errorMessage]: message.status === 'error' }]">
<VueMarkdown
:key="forceReRenderKey"
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
@ -211,7 +211,7 @@ onBeforeMount(() => {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color--background--light-3);
background: var(--color--background);
color: var(--color--text--tint-1);
.compact & {
@ -231,60 +231,112 @@ onBeforeMount(() => {
max-width: fit-content;
.user & {
padding: var(--spacing--4xs) var(--spacing--md);
padding: var(--spacing--3xs) var(--spacing--sm);
border-radius: var(--radius--xl);
background-color: var(--color--background);
}
}
> .chatMessageMarkdown {
display: block;
.errorMessage {
padding: var(--spacing--xs) var(--spacing--sm);
border-radius: var(--radius--lg);
background-color: var(--color--danger--tint-4);
border: var(--border-width) var(--border-style) var(--color--danger--tint-3);
color: var(--color--danger);
}
.chatMessageMarkdown {
display: block;
box-sizing: border-box;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
& * {
font-size: var(--font-size--md);
line-height: 1.8;
}
p {
margin: var(--spacing--xs) 0;
}
// Override heading sizes to be smaller
h1 {
font-size: var(--font-size--2xl);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--md);
}
h2 {
font-size: var(--font-size--xl);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--lg);
}
h3 {
font-size: var(--font-size--lg);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--lg);
}
h4 {
font-size: var(--font-size--md);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--xl);
}
h5 {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--xl);
}
h6 {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--xl);
}
pre {
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre-wrap;
box-sizing: border-box;
padding: var(--chat--spacing);
background: var(--chat--message--pre--background);
border-radius: var(--chat--border-radius);
}
> *:first-child {
margin-top: 0;
}
table {
width: 100%;
border-bottom: var(--border);
border-top: var(--border);
border-width: 2px;
margin-bottom: 1em;
border-color: var(--color--text--shade-1);
}
> *:last-child {
margin-bottom: 0;
}
th,
td {
padding: 0.25em 1em 0.25em 0;
}
& * {
font-size: var(--font-size--md);
line-height: 1.8;
}
th {
border-bottom: var(--border);
border-color: var(--color--text--shade-1);
}
p {
margin: var(--spacing--xs) 0;
}
pre {
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre-wrap;
box-sizing: border-box;
padding: var(--chat--spacing);
background: var(--chat--message--pre--background);
border-radius: var(--chat--border-radius);
}
table {
width: 100%;
border-bottom: var(--border);
border-top: var(--border);
border-width: 2px;
margin-bottom: 1em;
border-color: var(--color--text--shade-1);
}
th,
td {
padding: 0.25em 1em 0.25em 0;
}
th {
border-bottom: var(--border);
border-color: var(--color--text--shade-1);
ul,
ol {
li {
margin-bottom: 0.125rem;
}
}
}

View File

@ -87,7 +87,7 @@ function handleReadAloud() {
/>
<template #content>{{ isSpeaking ? 'Stop reading' : 'Read aloud' }}</template>
</N8nTooltip>
<N8nTooltip placement="bottom" :show-after="300">
<N8nTooltip v-if="message.status === 'success'" placement="bottom" :show-after="300">
<N8nIconButton icon="pen" type="tertiary" size="medium" text @click="handleEdit" />
<template #content>Edit</template>
</N8nTooltip>

View File

@ -83,6 +83,12 @@ const credentialsName = computed(() =>
? credentialsStore.getCredentialById(credentials?.[selectedAgent.model.provider] ?? '')?.name
: undefined,
);
const isCredentialsRequired = computed(
() =>
selectedAgent &&
selectedAgent.model.provider !== 'n8n' &&
selectedAgent.model.provider !== 'custom-agent',
);
const menu = computed(() => {
const menuItems: (typeof N8nNavigationDropdown)['menu'] = [];
@ -293,7 +299,7 @@ defineExpose({
<ChatAgentAvatar
v-if="selectedAgent"
:agent="selectedAgent"
:size="credentialsName ? 'md' : 'sm'"
:size="credentialsName || !isCredentialsRequired ? 'md' : 'sm'"
:class="$style.icon"
/>
<div :class="$style.selected">

View File

@ -2,7 +2,7 @@
import NodeIcon from '@/app/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { N8nButton } from '@n8n/design-system';
import type { INode } from 'n8n-workflow';
import { computed, onMounted } from 'vue';
import { TOOLS_SELECTOR_MODAL_KEY } from '../constants';
@ -54,6 +54,7 @@ const onClick = () => {
:class="$style.toolsButton"
:disabled="disabled"
aria-label="Select tools"
:icon="toolCount > 0 ? undefined : 'plus'"
@click="onClick"
>
<span v-if="toolCount" :class="$style.iconStack">
@ -67,11 +68,7 @@ const onClick = () => {
:size="12"
/>
</span>
<span v-else :class="$style.iconFallback">
<N8nIcon icon="plus" :size="12" />
</span>
<N8nText size="small" bold color="text-dark">{{ toolsLabel }}</N8nText>
{{ toolsLabel }}
</N8nButton>
</template>
@ -100,11 +97,4 @@ const onClick = () => {
.iconOverlap {
margin-left: -6px;
}
.iconFallback {
padding: var(--spacing--4xs);
display: flex;
align-items: center;
justify-content: center;
}
</style>