feat(editor): Chat conversation and message actions (no-changelog) (#20763)

This commit is contained in:
Suguru Inoue 2025-10-14 15:32:35 +02:00 committed by GitHub
parent 74c487f18c
commit 742be484fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 491 additions and 41 deletions

View File

@ -209,12 +209,20 @@ defineExpose({ open, close });
display: flex;
text-align: center;
margin-right: var(--spacing--2xs);
flex-grow: 0;
flex-shrink: 0;
margin-right: calc(-1 * var(--spacing--2xs));
svg {
width: 1.2em !important;
}
}
.label {
flex-grow: 1;
flex-shrink: 1;
}
.checkIcon {
flex-grow: 0;
flex-shrink: 0;

View File

@ -167,7 +167,7 @@ defineExpose({
<style lang="scss" module>
:global(.el-menu).dropdown {
border-bottom: 0;
border-bottom: 0 !important;
background-color: transparent;
> :global(.el-sub-menu) {

View File

@ -3,14 +3,19 @@ import { ref, computed, watch, nextTick, onMounted, useTemplateRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { v4 as uuidv4 } from 'uuid';
import { N8nIcon, N8nScrollArea, N8nIconButton } from '@n8n/design-system';
import { N8nScrollArea, N8nIconButton } from '@n8n/design-system';
import ModelSelector from './components/ModelSelector.vue';
import CredentialSelectorModal from './components/CredentialSelectorModal.vue';
import { useChatStore } from './chat.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useUIStore } from '@/stores/ui.store';
import { credentialsMapSchema, type CredentialsMap, type Suggestion } from './chat.types';
import {
type ChatMessage as ChatMessageType,
credentialsMapSchema,
type CredentialsMap,
type Suggestion,
} from './chat.types';
import {
chatHubConversationModelSchema,
type ChatHubProvider,
@ -33,7 +38,6 @@ import { findOneFromModelsResponse } from '@/features/chatHub/chat.utils';
import { useToast } from '@/composables/useToast';
import ChatMessage from '@/features/chatHub/components/ChatMessage.vue';
import ChatPrompt from '@/features/chatHub/components/ChatPrompt.vue';
import ChatTypingIndicator from '@/features/chatHub/components/ChatTypingIndicator.vue';
import ChatStarter from '@/features/chatHub/components/ChatStarter.vue';
import { useUsersStore } from '@/stores/users.store';
@ -123,6 +127,7 @@ const inputPlaceholder = computed(() => {
});
const scrollOnNewMessage = ref(true);
const editingMessageId = ref<string>();
const credentialsName = computed(() =>
selectedModel.value
@ -254,6 +259,23 @@ function onSubmit(message: string) {
function onSuggestionClick(s: Suggestion) {
inputRef.value?.setText(`${s.title} ${s.subtitle}`);
}
function handleStartEditMessage(messageId: string) {
editingMessageId.value = messageId;
}
function handleCancelEditMessage() {
editingMessageId.value = undefined;
}
async function handleUpdateMessage(message: ChatMessageType) {
if (message.type === 'error') {
return;
}
await chatStore.updateChatMessage(sessionId.value, message.id, message.text);
editingMessageId.value = undefined;
}
</script>
<template>
@ -301,16 +323,17 @@ function onSuggestionClick(s: Suggestion) {
/>
<div v-else ref="messagesRef" role="log" aria-live="polite" :class="$style.messageList">
<ChatMessage v-for="m in chatMessages" :key="m.id" :message="m" :compact="isMobileDevice" />
<div v-if="chatStore.isResponding" :class="[$style.message, $style.assistant]">
<div :class="$style.avatar">
<N8nIcon icon="sparkles" width="20" height="20" />
</div>
<div :class="$style.bubble">
<ChatTypingIndicator v-if="chatStore.isResponding" />
</div>
</div>
<ChatMessage
v-for="message in chatMessages"
:key="message.id"
:message="message"
:compact="isMobileDevice"
:is-editing="editingMessageId === message.id"
:is-streaming="chatStore.streamingMessageId === message.id"
@start-edit="handleStartEditMessage(message.id)"
@cancel-edit="handleCancelEditMessage"
@update="handleUpdateMessage"
/>
</div>
<div :class="$style.promptContainer">

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { CHAT_STORE } from './constants';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import {
fetchChatModelsApi,
@ -21,10 +21,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const rootStore = useRootStore();
const models = ref<ChatModelsResponse>();
const loadingModels = ref(false);
const isResponding = ref(false);
const streamingMessageId = ref<string>();
const messagesBySession = ref<Partial<Record<string, ChatMessage[]>>>({});
const sessions = ref<ChatHubSessionDto[]>([]);
const isResponding = computed(() => streamingMessageId.value !== undefined);
const getLastMessage = (sessionId: string) => {
const msgs = messagesBySession.value[sessionId];
if (!msgs || msgs.length === 0) return null;
@ -108,7 +110,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
function onBeginMessage(sessionId: string, messageId: string, nodeId: string, runIndex?: number) {
isResponding.value = true;
streamingMessageId.value = messageId;
addAiMessage(sessionId, '', messageId, `${messageId}-${nodeId}-${runIndex ?? 0}`);
}
@ -124,7 +126,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function onEndMessage(_messageId: string, _nodeId: string, _runIndex?: number) {
isResponding.value = false;
streamingMessageId.value = undefined;
}
function onStreamMessage(sessionId: string, message: StructuredChunk, messageId: string) {
@ -157,12 +159,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
function onStreamDone() {
isResponding.value = false;
streamingMessageId.value = undefined;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function onStreamError(_e: Error) {
isResponding.value = false;
streamingMessageId.value = undefined;
}
function askAI(
@ -194,16 +196,40 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
);
}
async function renameSession(sessionId: string, name: string) {
// Optimistic update
sessions.value = sessions.value.map((session) =>
session.id === sessionId ? { ...session, title: name } : session,
);
// TODO: call the endpoint
}
async function deleteSession(sessionId: string) {
// Optimistic update
sessions.value = sessions.value.filter((session) => session.id !== sessionId);
// TODO: call the endpoint
}
async function updateChatMessage(_sessionId: string, _messageId: string, _content: string) {
// TODO: call the endpoint
}
return {
models,
loadingModels,
messagesBySession,
isResponding,
streamingMessageId,
sessions,
fetchChatModels,
askAI,
addUserMessage,
fetchSessions,
fetchMessages,
renameSession,
deleteSession,
updateChatMessage,
};
});

View File

@ -1,12 +1,64 @@
<script setup lang="ts">
import type { ChatMessage } from '@/features/chatHub/chat.types';
import { N8nIcon } from '@n8n/design-system';
import { N8nIcon, N8nInput, N8nButton } from '@n8n/design-system';
import VueMarkdown from 'vue-markdown-render';
import hljs from 'highlight.js/lib/core';
import markdownLink from 'markdown-it-link-attributes';
import type MarkdownIt from 'markdown-it';
import ChatMessageActions from './ChatMessageActions.vue';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { ref, nextTick, watch } from 'vue';
import { useTemplateRef } from 'vue';
import ChatTypingIndicator from '@/features/chatHub/components/ChatTypingIndicator.vue';
const { message, compact } = defineProps<{ message: ChatMessage; compact: boolean }>();
const { message, compact, isEditing, isStreaming } = defineProps<{
message: ChatMessage;
compact: boolean;
isEditing: boolean;
isStreaming: boolean;
}>();
const emit = defineEmits<{
startEdit: [];
cancelEdit: [];
update: [message: ChatMessage];
regenerate: [message: ChatMessage];
}>();
const clipboard = useClipboard();
const toast = useToast();
const i18n = useI18n();
const editedText = ref('');
const textareaRef = useTemplateRef('textarea');
async function handleCopy() {
const text = messageText(message);
await clipboard.copy(text);
toast.showMessage({ title: i18n.baseText('generic.copiedToClipboard'), type: 'success' });
}
function handleEdit() {
emit('startEdit');
}
function handleCancelEdit() {
emit('cancelEdit');
}
function handleConfirmEdit() {
if (message.type === 'error' || !editedText.value.trim()) {
return;
}
emit('update', { ...message, text: editedText.value });
}
function handleRegenerate() {
emit('regenerate', message);
}
function messageText(msg: ChatMessage) {
return msg.type === 'message' ? msg.text : `**Error:** ${msg.content}`;
@ -32,6 +84,21 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
},
});
};
// Watch for isEditing prop changes to initialize edit mode
watch(
() => isEditing,
async (editing) => {
if (editing) {
editedText.value = messageText(message);
await nextTick();
textareaRef.value?.focus();
} else {
editedText.value = '';
}
},
{ immediate: true },
);
</script>
<template>
@ -47,13 +114,46 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
<div :class="$style.avatar">
<N8nIcon :icon="message.role === 'user' ? 'user' : 'sparkles'" width="20" height="20" />
</div>
<div :class="$style.chatMessage">
<VueMarkdown
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
:source="messageText(message)"
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
/>
<div :class="$style.content">
<div v-if="isEditing" :class="$style.editContainer">
<N8nInput
ref="textarea"
v-model="editedText"
type="textarea"
:autosize="{ minRows: 3, maxRows: 20 }"
:class="$style.textarea"
/>
<div :class="$style.editActions">
<N8nButton type="secondary" size="small" @click="handleCancelEdit"> Cancel </N8nButton>
<N8nButton
type="primary"
size="small"
:disabled="!editedText.trim()"
@click="handleConfirmEdit"
>
Save
</N8nButton>
</div>
</div>
<template v-else>
<div :class="$style.chatMessage">
<VueMarkdown
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
:source="messageText(message)"
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
/>
</div>
<ChatTypingIndicator v-if="isStreaming" :class="$style.typingIndicator" />
<ChatMessageActions
v-else
:role="message.role"
:class="$style.actions"
@copy="handleCopy"
@edit="handleEdit"
@regenerate="handleRegenerate"
/>
</template>
</div>
</div>
</template>
@ -82,6 +182,11 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
}
}
.content {
display: flex;
flex-direction: column;
}
.chatMessage {
display: block;
position: relative;
@ -122,4 +227,28 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
}
}
}
.actions {
margin-top: var(--spacing--2xs);
}
.editContainer {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.textarea {
width: 100%;
}
.editActions {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
}
.typingIndicator {
margin-top: var(--spacing--xs);
}
</style>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import { N8nIconButton } from '@n8n/design-system';
const { role } = defineProps<{ role: 'user' | 'assistant' }>();
const emit = defineEmits<{
copy: [];
edit: [];
regenerate: [];
}>();
function handleCopy() {
emit('copy');
}
function handleEdit() {
emit('edit');
}
function handleRegenerate() {
emit('regenerate');
}
</script>
<template>
<div :class="$style.actions">
<N8nIconButton icon="copy" type="tertiary" size="small" text @click="handleCopy" />
<N8nIconButton icon="pen" type="tertiary" size="small" text @click="handleEdit" />
<N8nIconButton
v-if="role === 'assistant'"
icon="refresh-cw"
type="tertiary"
size="small"
text
@click="handleRegenerate"
/>
</div>
</template>
<style lang="scss" module>
.actions {
display: flex;
align-items: center;
}
</style>

View File

@ -0,0 +1,180 @@
<script setup lang="ts">
import { CHAT_CONVERSATION_VIEW } from '@/features/chatHub/constants';
import { N8nActionDropdown, N8nIcon, N8nIconButton, N8nInput, N8nText } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
const { sessionId, label, active, isRenaming } = defineProps<{
sessionId: string;
label: string;
active: boolean;
isRenaming: boolean;
}>();
const emit = defineEmits<{
startRename: [sessionId: string];
cancelRename: [];
confirmRename: [sessionId: string, newLabel: string];
delete: [sessionId: string];
}>();
const input = useTemplateRef('input');
const editedLabel = ref(label);
type SessionAction = 'rename' | 'delete';
const dropdownItems = computed<Array<ActionDropdownItem<SessionAction>>>(() => [
{
id: 'rename',
label: 'Rename',
icon: 'pencil',
},
{
id: 'delete',
label: 'Delete',
icon: 'trash-2',
},
]);
function handleActionSelect(action: SessionAction) {
if (action === 'rename') {
editedLabel.value = label;
emit('startRename', sessionId);
} else if (action === 'delete') {
emit('delete', sessionId);
}
}
function handleBlur() {
const trimmed = editedLabel.value.trim();
if (trimmed && trimmed !== label) {
emit('confirmRename', sessionId, trimmed);
} else {
emit('cancelRename');
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('cancelRename');
return;
}
if (e.key === 'Enter') {
handleBlur();
}
}
watch(
() => isRenaming,
async (renaming) => {
if (renaming) {
editedLabel.value = label;
await nextTick();
input.value?.focus();
input.value?.select();
} else {
editedLabel.value = '';
}
},
);
</script>
<template>
<div :class="[$style.menuItem, { [$style.active]: active }]">
<N8nInput
v-if="isRenaming"
size="small"
ref="input"
v-model="editedLabel"
@blur="handleBlur"
@keydown="handleKeyDown"
/>
<template v-else>
<RouterLink
:to="{ name: CHAT_CONVERSATION_VIEW, params: { id: sessionId } }"
:class="$style.menuItemLink"
>
<N8nIcon size="small" icon="message-circle" />
<N8nText :class="$style.label">{{ label }}</N8nText>
</RouterLink>
<N8nActionDropdown
:items="dropdownItems"
:class="$style.actionDropdown"
placement="bottom-start"
@select="handleActionSelect"
@click.stop
>
<template #activator>
<N8nIconButton
icon="ellipsis-vertical"
type="tertiary"
text
:class="$style.actionDropdownTrigger"
/>
</template>
</N8nActionDropdown>
</template>
</div>
</template>
<style lang="scss" module>
.menuItem {
display: flex;
align-items: center;
border-radius: var(--spacing--4xs);
padding-right: 0;
&.active,
&:hover {
background-color: var(--color--foreground);
}
}
.menuItemLink {
display: flex;
align-items: center;
padding: var(--spacing--3xs);
gap: var(--spacing--3xs);
cursor: pointer;
color: var(--color--text);
min-width: 0;
flex: 1;
text-decoration: none;
&:focus-visible {
outline: 1px solid var(--color--secondary);
outline-offset: -1px;
border-radius: var(--spacing--4xs);
}
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
line-height: var(--font-size--lg);
min-width: 0;
}
.actionDropdown {
opacity: 0;
transition: opacity 0.2s;
flex-shrink: 0;
width: 0;
.menuItem:has(:focus) &,
.menuItem:hover &,
.active & {
width: auto;
opacity: 1;
}
}
.actionDropdownTrigger {
box-shadow: none !important;
outline: none !important;
}
</style>

View File

@ -1,13 +1,16 @@
<script setup lang="ts">
import MainSidebarUserArea from '@/components/MainSidebarUserArea.vue';
import { CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY, VIEWS } from '@/constants';
import { CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY, MODAL_CONFIRM, VIEWS } from '@/constants';
import { useChatStore } from '@/features/chatHub/chat.store';
import { groupConversationsByDate } from '@/features/chatHub/chat.utils';
import { CHAT_CONVERSATION_VIEW, CHAT_VIEW } from '@/features/chatHub/constants';
import { CHAT_VIEW } from '@/features/chatHub/constants';
import { useUIStore } from '@/stores/ui.store';
import { N8nIcon, N8nIconButton, N8nMenuItem, N8nScrollArea, N8nText } from '@n8n/design-system';
import { computed, onMounted } from 'vue';
import { N8nIcon, N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ChatSessionMenuItem from './ChatSessionMenuItem.vue';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
defineProps<{ isMobileDevice: boolean }>();
@ -15,6 +18,10 @@ const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const uiStore = useUIStore();
const toast = useToast();
const message = useMessage();
const renamingSessionId = ref<string>();
const currentSessionId = computed(() =>
typeof route.params.id === 'string' ? route.params.id : undefined,
@ -37,6 +44,37 @@ function onNewChat() {
});
}
function handleStartRename(sessionId: string) {
renamingSessionId.value = sessionId;
}
function handleCancelRename() {
renamingSessionId.value = undefined;
}
async function handleConfirmRename(sessionId: string, newLabel: string) {
await chatStore.renameSession(sessionId, newLabel);
renamingSessionId.value = undefined;
}
async function handleDeleteSession(sessionId: string) {
const confirmed = await message.confirm(
'Are you sure you want to delete this conversation?',
'Delete conversation',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
},
);
if (confirmed !== MODAL_CONFIRM) {
return;
}
await chatStore.deleteSession(sessionId);
toast.showMessage({ type: 'success', title: 'Conversation is deleted' });
}
onMounted(async () => {
await chatStore.fetchSessions();
});
@ -64,16 +102,17 @@ onMounted(async () => {
<N8nText :class="$style.groupHeader" size="small" bold color="text-light">
{{ group.group }}
</N8nText>
<N8nMenuItem
<ChatSessionMenuItem
v-for="session in group.sessions"
:key="session.id"
:session-id="session.id"
:label="session.label"
:active="currentSessionId === session.id"
:item="{
id: session.id,
icon: 'message-circle',
label: session.label,
route: { to: { name: CHAT_CONVERSATION_VIEW, params: { id: session.id } } },
}"
:is-renaming="renamingSessionId === session.id"
@start-rename="handleStartRename"
@cancel-rename="handleCancelRename"
@confirm-rename="handleConfirmRename"
@delete="handleDeleteSession"
/>
</div>
</div>
@ -125,6 +164,7 @@ onMounted(async () => {
.group {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.groupHeader {

View File

@ -76,7 +76,6 @@ function onCancel() {
:model-value="selectedCredentialId"
size="large"
placeholder="Select credential..."
data-test-id="credential-select"
@update:model-value="onCredentialSelect"
>
<N8nOption