Files
n8n-enterprise-unlocked/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue
2025-08-28 10:14:03 +02:00

529 lines
12 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import MessageWrapper from './messages/MessageWrapper.vue';
import { useI18n } from '../../composables/useI18n';
import type { ChatUI, RatingFeedback } from '../../types/assistant';
import { isToolMessage } from '../../types/assistant';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
const { t } = useI18n();
const MAX_CHAT_INPUT_HEIGHT = 100;
interface Props {
user?: {
firstName: string;
lastName: string;
};
messages?: ChatUI.AssistantMessage[];
streaming?: boolean;
loadingMessage?: string;
sessionId?: string;
title?: string;
placeholder?: string;
scrollOnNewMessage?: boolean;
showStop?: boolean;
maxLength?: number;
}
const emit = defineEmits<{
close: [];
stop: [];
message: [string, string?, boolean?];
codeReplace: [number];
codeUndo: [number];
feedback: [RatingFeedback];
}>();
const onClose = () => emit('close');
const props = withDefaults(defineProps<Props>(), {
title: () => useI18n().t('assistantChat.aiAssistantLabel'),
user: () => ({
firstName: '',
lastName: '',
}),
messages: () => [],
loadingMessage: undefined,
sessionId: undefined,
scrollOnNewMessage: false,
});
function normalizeMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
return messages.map((msg, index) => ({
...msg,
id: msg.id || `msg-${index}`,
read: msg.read ?? true,
}));
}
function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
const result: ChatUI.AssistantMessage[] = [];
let i = 0;
while (i < messages.length) {
const currentMsg = messages[i];
// If it's not a tool message, add it as-is and continue
if (!isToolMessage(currentMsg)) {
result.push(currentMsg);
i++;
continue;
}
// Collect consecutive tool messages with the same toolName
const toolMessagesGroup = [currentMsg];
let j = i + 1;
while (j < messages.length) {
const nextMsg = messages[j];
if (isToolMessage(nextMsg) && nextMsg.toolName === currentMsg.toolName) {
toolMessagesGroup.push(nextMsg);
j++;
} else {
break;
}
}
// If we have multiple tool messages with the same toolName, collapse them
if (toolMessagesGroup.length > 1) {
// Determine the status to show based on priority rules
const lastMessage = toolMessagesGroup[toolMessagesGroup.length - 1];
let titleSource = lastMessage;
// Check if we have running messages - if so, show the last running one and use its titles
const runningMessages = toolMessagesGroup.filter((msg) => msg.status === 'running');
const errorMessage = toolMessagesGroup.find((msg) => msg.status === 'error');
if (runningMessages.length > 0) {
const lastRunning = runningMessages[runningMessages.length - 1];
titleSource = lastRunning;
} else if (errorMessage) {
titleSource = errorMessage;
}
// Combine all updates from all tool messages
const combinedUpdates = toolMessagesGroup.flatMap((msg) => msg.updates || []);
// Create collapsed message with title logic based on final status
const collapsedMessage: ChatUI.ToolMessage = {
...lastMessage,
status: titleSource.status,
updates: combinedUpdates,
displayTitle: titleSource.displayTitle,
// Only set customDisplayTitle if status is running (for example "Adding X node")
customDisplayTitle:
titleSource.status === 'running' ? titleSource.customDisplayTitle : undefined,
};
result.push(collapsedMessage);
} else {
// Single tool message, add as-is
result.push(currentMsg);
}
i = j;
}
return result;
}
// Ensure all messages have required id and read properties, and collapse tool messages
const normalizedMessages = computed(() => {
const normalized = normalizeMessages(props.messages);
return collapseToolMessages(normalized);
});
const textInputValue = ref<string>('');
const chatInput = ref<HTMLTextAreaElement | null>(null);
const messagesRef = ref<HTMLDivElement | null>(null);
const sessionEnded = computed(() => {
return isEndOfSessionEvent(props.messages?.[props.messages.length - 1]);
});
const sendDisabled = computed(() => {
return !textInputValue.value || props.streaming || sessionEnded.value;
});
const showPlaceholder = computed(() => {
return !props.messages?.length && !props.loadingMessage && !props.sessionId;
});
function isEndOfSessionEvent(event?: ChatUI.AssistantMessage) {
return event?.type === 'event' && event?.eventName === 'end-session';
}
function onQuickReply(opt: ChatUI.QuickReply) {
emit('message', opt.text, opt.type, opt.isFeedback);
}
function onSendMessage() {
if (sendDisabled.value) return;
emit('message', textInputValue.value);
textInputValue.value = '';
if (chatInput.value) {
chatInput.value.style.height = 'auto';
}
}
function growInput() {
if (!chatInput.value) return;
chatInput.value.style.height = 'auto';
const scrollHeight = chatInput.value.scrollHeight;
chatInput.value.style.height = `${Math.min(scrollHeight, MAX_CHAT_INPUT_HEIGHT)}px`;
}
function onRateMessage(feedback: RatingFeedback) {
emit('feedback', feedback);
}
function scrollToBottom() {
if (messagesRef.value) {
messagesRef.value?.scrollTo({
top: messagesRef.value.scrollHeight,
behavior: 'smooth',
});
}
}
watch(sendDisabled, () => {
chatInput.value?.focus();
});
watch(
() => props.messages,
async (messages) => {
// Check if the last message is user and scroll to bottom of the chat
if (props.scrollOnNewMessage && messages.length > 0) {
// Wait for DOM updates before scrolling
await nextTick();
// Check if messagesRef is available after nextTick
if (messagesRef.value) {
scrollToBottom();
}
}
},
{ immediate: true, deep: true },
);
</script>
<template>
<div :class="$style.container">
<div :class="$style.header">
<div :class="$style.chatTitle">
<div :class="$style.headerText">
<AssistantIcon size="large" />
<AssistantText size="large" :text="title" />
</div>
<slot name="header" />
</div>
<div :class="$style.back" data-test-id="close-chat-button" @click="onClose">
<N8nIcon icon="arrow-right" color="text-base" />
</div>
</div>
<div :class="$style.body">
<div
v-if="normalizedMessages?.length || loadingMessage"
ref="messagesRef"
:class="$style.messages"
>
<div v-if="normalizedMessages?.length">
<data
v-for="(message, i) in normalizedMessages"
:key="message.id"
:data-test-id="
message.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user'
"
>
<MessageWrapper
:message="message"
:is-first-of-role="i === 0 || message.role !== normalizedMessages[i - 1].role"
:user="user"
:streaming="streaming"
:is-last-message="i === normalizedMessages.length - 1"
@code-replace="() => emit('codeReplace', i)"
@code-undo="() => emit('codeUndo', i)"
@feedback="onRateMessage"
/>
<div
v-if="
!streaming &&
'quickReplies' in message &&
message.quickReplies?.length &&
i === normalizedMessages.length - 1
"
:class="$style.quickReplies"
>
<div :class="$style.quickRepliesTitle">
{{ t('assistantChat.quickRepliesTitle') }}
</div>
<div v-for="opt in message.quickReplies" :key="opt.type" data-test-id="quick-replies">
<N8nButton
v-if="opt.text"
type="secondary"
size="mini"
@click="() => onQuickReply(opt)"
>
{{ opt.text }}
</N8nButton>
</div>
</div>
</data>
</div>
<div
v-if="loadingMessage"
:class="{ [$style.message]: true, [$style.loading]: normalizedMessages?.length }"
>
<AssistantLoadingMessage :message="loadingMessage" />
</div>
</div>
<div
v-else-if="showPlaceholder"
:class="$style.placeholder"
data-test-id="placeholder-message"
>
<div v-if="$slots.placeholder" :class="$style.info">
<slot name="placeholder" />
</div>
<template v-else>
<div :class="$style.greeting">Hi {{ user?.firstName }} 👋</div>
<div :class="$style.info">
<p>
{{ t('assistantChat.placeholder.1') }}
</p>
<p>
{{ t('assistantChat.placeholder.2') }}
<InlineAskAssistantButton size="small" :static="true" />
{{ t('assistantChat.placeholder.3') }}
</p>
<p>
{{ t('assistantChat.placeholder.4') }}
</p>
</div>
</template>
</div>
</div>
<div
:class="{ [$style.inputWrapper]: true, [$style.disabledInput]: sessionEnded }"
data-test-id="chat-input-wrapper"
>
<div v-if="$slots.inputPlaceholder" :class="$style.inputPlaceholder">
<slot name="inputPlaceholder" />
</div>
<template v-else>
<textarea
ref="chatInput"
v-model="textInputValue"
class="ignore-key-press-node-creator ignore-key-press-canvas"
:class="{ [$style.disabled]: sessionEnded || streaming }"
:disabled="sessionEnded || streaming"
:placeholder="placeholder ?? t('assistantChat.inputPlaceholder')"
rows="1"
wrap="hard"
:maxlength="maxLength"
data-test-id="chat-input"
@keydown.enter.exact.prevent="onSendMessage"
@input.prevent="growInput"
@keydown.stop
/>
<N8nButton
v-if="showStop && streaming"
:class="$style.stopButton"
icon="square"
size="large"
type="danger"
outline
square
data-test-id="send-message-button"
@click="emit('stop')"
/>
<N8nButton
v-else
:class="$style.sendButton"
icon="send"
:text="true"
size="large"
square
data-test-id="send-message-button"
:disabled="sendDisabled"
@click="onSendMessage"
/>
</template>
</div>
</div>
</template>
<style lang="scss" module>
.container {
height: 100%;
position: relative;
display: grid;
grid-template-rows: auto 1fr auto;
}
:root .stopButton {
--button-border-color: transparent;
}
.header {
height: 65px; // same as header height in editor
padding: 0 var(--spacing-l);
background-color: var(--color-background-xlight);
border: var(--border-base);
border-top: 0;
display: flex;
div {
display: flex;
align-items: center;
}
> div:first-of-type {
width: 100%;
}
}
.body {
background-color: var(--color-background-light);
border: var(--border-base);
border-top: 0;
position: relative;
pre,
code {
text-wrap: wrap;
}
}
.placeholder {
padding: var(--spacing-s);
}
.messages {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: var(--spacing-xs);
overflow-y: auto;
@supports not (selector(::-webkit-scrollbar)) {
scrollbar-width: thin;
}
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: var(--spacing-2xs);
}
&::-webkit-scrollbar-thumb {
border-radius: var(--spacing-xs);
background: var(--color-foreground-dark);
border: var(--spacing-5xs) solid white;
}
}
& + & {
padding-top: 0;
}
}
.message {
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-xloose);
&.loading {
margin-top: var(--spacing-m);
}
}
.chatTitle {
display: flex;
gap: var(--spacing-3xs);
}
.headerText {
gap: var(--spacing-xs);
}
.greeting {
color: var(--color-text-dark);
font-size: var(--font-size-m);
margin-bottom: var(--spacing-s);
}
.info {
font-size: var(--font-size-s);
color: var(--color-text-base);
button {
display: inline-flex;
}
}
.back:hover {
cursor: pointer;
}
.quickReplies {
margin-top: var(--spacing-s);
> * {
margin-bottom: var(--spacing-3xs);
}
}
.quickRepliesTitle {
font-size: var(--font-size-3xs);
color: var(--color-text-base);
}
.inputWrapper {
display: flex;
background-color: var(--color-foreground-xlight);
border: var(--border-base);
width: 100%;
border-top: 0;
textarea {
border: none;
background-color: transparent;
width: 100%;
font-size: var(--spacing-xs);
padding: var(--spacing-xs);
outline: none;
color: var(--color-text-dark);
resize: none;
font-family: inherit;
}
}
.sendButton {
color: var(--color-text-base) !important;
&[disabled] {
color: var(--color-text-light) !important;
}
}
.disabledInput {
cursor: not-allowed;
* {
cursor: not-allowed;
}
}
textarea.disabled {
background-color: var(--color-foreground-base);
cursor: not-allowed;
}
.inputPlaceholder {
width: 100%;
}
</style>