mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 08:17:06 +02:00
feat(editor): Chat conversation and message actions (no-changelog) (#20763)
This commit is contained in:
parent
74c487f18c
commit
742be484fa
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ function onCancel() {
|
|||
:model-value="selectedCredentialId"
|
||||
size="large"
|
||||
placeholder="Select credential..."
|
||||
data-test-id="credential-select"
|
||||
@update:model-value="onCredentialSelect"
|
||||
>
|
||||
<N8nOption
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user