mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 02:07:06 +02:00
fix: Chat UI feedback (no-changelog) (#22010)
This commit is contained in:
parent
fe297d09ab
commit
fe871058e2
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export interface ChatStreamingState extends Partial<EnrichedStructuredChunk['met
|
|||
promptId: ChatMessageId;
|
||||
sessionId: ChatSessionId;
|
||||
model: ChatHubConversationModel;
|
||||
retryOfMessageId: ChatMessageId | null;
|
||||
}
|
||||
|
||||
export interface FlattenedModel {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user