Files
n8n-enterprise-unlocked/packages/frontend/editor-ui/src/features/logs/components/ChatMessagesPanel.vue
2025-06-17 11:24:48 +03:00

385 lines
11 KiB
Vue

<script setup lang="ts">
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import { useI18n } from '@n8n/i18n';
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import MessageOptionTooltip from './MessageOptionTooltip.vue';
import MessageOptionAction from './MessageOptionAction.vue';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import ChatInput from '@n8n/chat/components/Input.vue';
import { computed, ref } from 'vue';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
import LogsPanelHeader from '@/features/logs/components/LogsPanelHeader.vue';
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
interface Props {
pastChatMessages: string[];
messages: ChatMessage[];
sessionId: string;
showCloseButton?: boolean;
isOpen?: boolean;
isReadOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isOpen: true,
isReadOnly: false,
});
const emit = defineEmits<{
displayExecution: [id: string];
sendMessage: [message: string];
refreshSession: [];
close: [];
clickHeader: [];
}>();
const clipboard = useClipboard();
const locale = useI18n();
const toast = useToast();
const previousMessageIndex = ref(0);
const sessionIdText = computed(() =>
locale.baseText('chat.window.session.id', {
interpolate: { id: `${props.sessionId.slice(0, 5)}...` },
}),
);
const inputPlaceholder = computed(() => {
if (props.messages.length > 0) {
return locale.baseText('chat.window.chat.placeholder');
}
return locale.baseText('chat.window.chat.placeholderPristine');
});
/** Checks if message is a text message */
function isTextMessage(message: ChatMessage): message is ChatMessageText {
return message.type === 'text' || !message.type;
}
/** Reposts the message */
function repostMessage(message: ChatMessageText) {
void sendMessage(message.text);
}
/** Sets the message in input for reuse */
function reuseMessage(message: ChatMessageText) {
chatEventBus.emit('setInputValue', message.text);
}
function sendMessage(message: string) {
previousMessageIndex.value = 0;
emit('sendMessage', message);
}
function onRefreshSession() {
emit('refreshSession');
}
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
const pastMessages = props.pastChatMessages;
const isCurrentInputEmptyOrMatch =
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
// Exit if no messages
if (pastMessages.length === 0) return;
// Temporarily blur to avoid cursor position issues
chatEventBus.emit('blurInput');
if (pastMessages.length === 1) {
previousMessageIndex.value = 0;
} else {
if (key === 'ArrowUp') {
if (currentInputValue.length === 0 && previousMessageIndex.value === 0) {
// Start with most recent message
previousMessageIndex.value = pastMessages.length - 1;
} else {
// Move backwards through history
previousMessageIndex.value =
previousMessageIndex.value === 0
? pastMessages.length - 1
: previousMessageIndex.value - 1;
}
} else if (key === 'ArrowDown') {
// Move forwards through history
previousMessageIndex.value =
previousMessageIndex.value === pastMessages.length - 1
? 0
: previousMessageIndex.value + 1;
}
}
// Get message at current index
const selectedMessage = pastMessages[previousMessageIndex.value];
chatEventBus.emit('setInputValue', selectedMessage);
// Refocus and move cursor to end
chatEventBus.emit('focusInput');
}
// Reset history navigation when typing new content that doesn't match history
if (!isCurrentInputEmptyOrMatch) {
previousMessageIndex.value = 0;
}
}
async function copySessionId() {
await clipboard.copy(props.sessionId);
toast.showMessage({
title: locale.baseText('generic.copiedToClipboard'),
message: '',
type: 'success',
});
}
</script>
<template>
<div
:class="$style.chat"
data-test-id="workflow-lm-chat-dialog"
class="ignore-key-press-canvas"
tabindex="0"
>
<LogsPanelHeader
data-test-id="chat-header"
:title="locale.baseText('chat.window.title')"
@click="emit('clickHeader')"
>
<template #actions>
<N8nTooltip v-if="clipboard.isSupported && !isReadOnly">
<template #content>
{{ sessionId }}
<br />
{{ locale.baseText('chat.window.session.id.copy') }}
</template>
<N8nButton
data-test-id="chat-session-id"
type="secondary"
size="mini"
:class="$style.newHeaderButton"
@click.stop="copySessionId"
>{{ sessionIdText }}</N8nButton
>
</N8nTooltip>
<N8nTooltip
v-if="messages.length > 0 && !isReadOnly"
:content="locale.baseText('chat.window.session.resetSession')"
>
<N8nIconButton
:class="$style.newHeaderButton"
data-test-id="refresh-session-button"
outline
type="secondary"
size="small"
icon-size="medium"
icon="undo"
:title="locale.baseText('chat.window.session.reset')"
@click.stop="onRefreshSession"
/>
</N8nTooltip>
</template>
</LogsPanelHeader>
<main v-if="isOpen" :class="$style.chatBody" data-test-id="canvas-chat-body">
<MessagesList
:messages="messages"
:class="$style.messages"
:empty-text="locale.baseText('chat.window.chat.emptyChatMessage.v2')"
>
<template #beforeMessage="{ message }">
<MessageOptionTooltip
v-if="!isReadOnly && message.sender === 'bot' && !message.id.includes('preload')"
placement="right"
data-test-id="execution-id-tooltip"
>
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
<a href="#" @click="emit('displayExecution', message.id)">{{ message.id }}</a>
</MessageOptionTooltip>
<MessageOptionAction
v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
data-test-id="repost-message-button"
icon="redo"
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
placement="left"
@click.once="repostMessage(message)"
/>
<MessageOptionAction
v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
data-test-id="reuse-message-button"
icon="copy"
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
placement="left"
@click="reuseMessage(message)"
/>
</template>
</MessagesList>
</main>
<div v-if="isOpen" :class="$style.messagesInput">
<ChatInput
data-test-id="lm-chat-inputs"
:placeholder="inputPlaceholder"
@arrow-key-down="onArrowKeyDown"
>
<template v-if="pastChatMessages.length > 0" #leftPanel>
<div :class="$style.messagesHistory">
<N8nButton
title="Navigate to previous message"
icon="chevron-up"
type="tertiary"
text
size="mini"
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
/>
<N8nButton
title="Navigate to next message"
icon="chevron-down"
type="tertiary"
text
size="mini"
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowDown' })"
/>
</div>
</template>
</ChatInput>
</div>
</div>
</template>
<style lang="scss" module>
.chat {
--chat--spacing: var(--spacing-xs);
--chat--message--padding: var(--spacing-2xs);
--chat--message--font-size: var(--font-size-xs);
--chat--input--font-size: var(--font-size-s);
--chat--input--placeholder--font-size: var(--font-size-xs);
--chat--message--bot--background: transparent;
--chat--message--user--background: var(--color-text-lighter);
--chat--message--bot--color: var(--color-text-dark);
--chat--message--user--color: var(--color-text-dark);
--chat--message--bot--border: none;
--chat--message--user--border: none;
--chat--message--user--border: none;
--chat--input--padding: var(--spacing-xs);
--chat--color-typing: var(--color-text-light);
--chat--textarea--max-height: calc(var(--panel-height) * 0.3);
--chat--message--pre--background: var(--color-foreground-light);
--chat--textarea--height: 2.5rem;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--color-background-light);
}
.chatHeader {
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
line-height: 18px;
text-align: left;
border-bottom: 1px solid var(--color-foreground-base);
padding: var(--chat--spacing);
background-color: var(--color-foreground-xlight);
display: flex;
justify-content: space-between;
align-items: center;
}
.chatTitle {
font-weight: var(--font-weight-medium);
}
.session {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
color: var(--color-text-base);
max-width: 70%;
}
.sessionId {
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&.copyable {
cursor: pointer;
}
}
.headerButton {
max-height: 1.1rem;
border: none;
}
.newHeaderButton {
border: none;
color: var(--color-text-light);
}
.chatBody {
display: flex;
height: 100%;
overflow: auto;
flex-direction: column;
align-items: center;
justify-content: center;
}
.messages {
border-radius: var(--border-radius-base);
height: 100%;
width: 100%;
overflow: auto;
padding-top: var(--spacing-l);
&:not(:last-child) {
margin-right: 1em;
}
}
.messagesInput {
--input-border-color: var(--border-color-base);
--chat--input--border: none;
--chat--input--border-radius: 0.5rem;
--chat--input--send--button--background: transparent;
--chat--input--send--button--color: var(--color-primary);
--chat--input--file--button--background: transparent;
--chat--input--file--button--color: var(--color-primary);
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
--chat--files-spacing: var(--spacing-2xs);
--chat--input--background: transparent;
--chat--input--file--button--color: var(--color-button-secondary-font);
--chat--input--file--button--color-hover: var(--color-primary);
[data-theme='dark'] & {
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
}
@media (prefers-color-scheme: dark) {
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
}
padding: var(--spacing-5xs);
margin: 0 var(--chat--spacing) var(--chat--spacing);
flex-grow: 1;
display: flex;
background: var(--color-lm-chat-bot-background);
border-radius: var(--chat--input--border-radius);
transition: border-color 200ms ease-in-out;
border: var(--input-border-color, var(--border-color-base))
var(--input-border-style, var(--border-style-base))
var(--input-border-width, var(--border-width-base));
&:focus-within {
--input-border-color: #4538a3;
}
}
</style>