fix(editor): Chat UI feedback (no-changelog) (#22678)

This commit is contained in:
Suguru Inoue
2025-12-03 17:29:03 +01:00
committed by GitHub
parent 241bb0fe59
commit deb4c208b0
7 changed files with 208 additions and 71 deletions
@@ -33,6 +33,7 @@ defineProps<{
menu: Array<Item | Divider>;
disabled?: boolean;
teleport?: boolean;
submenuClass?: string;
}>();
const menuRef = ref<typeof ElMenu | null>(null);
@@ -91,7 +92,7 @@ defineExpose({
:index="ROOT_MENU_INDEX"
:class="$style.trigger"
:popper-offset="-10"
:popper-class="$style.submenu"
:popper-class="[$style.submenu, submenuClass ?? ''].join(' ')"
:disabled
:teleported="teleport"
>
@@ -14,7 +14,7 @@ import { N8nButton, N8nHeading, N8nInput, N8nInputLabel, N8nSpinner } from '@n8n
import { useI18n } from '@n8n/i18n';
import { assert } from '@n8n/utils/assert';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed, ref, watch } from 'vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
import type { CredentialsMap } from '../chat.types';
import type { INode } from 'n8n-workflow';
import ToolsSelector from './ToolsSelector.vue';
@@ -48,7 +48,9 @@ const systemPrompt = ref('');
const selectedModel = ref<ChatHubBaseLLMModel | null>(null);
const isSaving = ref(false);
const isDeleting = ref(false);
const isOpened = ref(false);
const tools = ref<INode[]>([]);
const nameInputRef = useTemplateRef('nameInput');
const agentSelectedCredentials = ref<CredentialsMap>({});
const credentialIdForSelectedModelProvider = computed(
@@ -91,6 +93,10 @@ const canSelectTools = computed(
() => selectedAgent.value?.metadata.capabilities.functionCalling ?? false,
);
modalBus.value.once('opened', () => {
isOpened.value = true;
});
// If the agent doesn't support tools anymore, reset tools
watch(
selectedAgent,
@@ -120,6 +126,18 @@ watch(
{ immediate: true },
);
watch(
[isOpened, isLoadingAgent, nameInputRef],
async ([opened, isLoading, name]) => {
if (opened && !isLoading) {
// autofocus attribute doesn't work in modal
// https://github.com/element-plus/element-plus/issues/15250
name?.focus();
}
},
{ immediate: true, flush: 'post' },
);
function onCredentialSelected(provider: ChatHubProvider, credentialId: string | null) {
agentSelectedCredentials.value = {
...agentSelectedCredentials.value,
@@ -249,6 +267,7 @@ function onSelectTools() {
:required="true"
>
<N8nInput
ref="nameInput"
id="agent-name"
v-model="name"
:placeholder="i18n.baseText('chatHub.agent.editor.name.placeholder')"
@@ -374,6 +393,7 @@ function onSelectTools() {
.row {
display: flex;
flex-direction: row;
gap: var(--spacing--sm);
}
.footer {
@@ -1,14 +1,11 @@
<script setup lang="ts">
import { useClipboard } from '@/app/composables/useClipboard';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue';
import { useChatHubMarkdownOptions } from '@/features/ai/chatHub/composables/useChatHubMarkdownOptions';
import type { ChatMessageId, ChatModelDto } from '@n8n/api-types';
import { N8nButton, N8nIcon, N8nInput } from '@n8n/design-system';
import { useSpeechSynthesis } from '@vueuse/core';
import type MarkdownIt from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import { computed, onBeforeMount, ref, useTemplateRef, watch } from 'vue';
import { computed, onBeforeMount, ref, useCssModule, useTemplateRef, watch } from 'vue';
import VueMarkdown from 'vue-markdown-render';
import type { ChatMessage } from '../chat.types';
import ChatMessageActions from './ChatMessageActions.vue';
@@ -19,6 +16,7 @@ import { buildChatAttachmentUrl } from '@/features/ai/chatHub/chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useI18n } from '@n8n/i18n';
import CopyButton from '@/features/ai/chatHub/components/CopyButton.vue';
const { message, compact, isEditing, isStreaming, minHeight, cachedAgentDisplayName } =
defineProps<{
@@ -41,16 +39,16 @@ const emit = defineEmits<{
switchAlternative: [messageId: ChatMessageId];
}>();
const clipboard = useClipboard();
const chatStore = useChatStore();
const rootStore = useRootStore();
const { isCtrlKeyPressed } = useDeviceSupport();
const i18n = useI18n();
const styles = useCssModule();
const editedText = ref('');
const hoveredCodeBlockActions = ref<HTMLElement | null>(null);
const textareaRef = useTemplateRef('textarea');
const justCopied = ref(false);
const { markdownOptions, forceReRenderKey } = useChatHubMarkdownOptions();
const markdown = useChatHubMarkdownOptions(styles.codeBlockActions);
const messageContent = computed(() => message.content);
const speech = useSpeechSynthesis(messageContent, {
@@ -81,14 +79,11 @@ const attachments = computed(() =>
})),
);
async function handleCopy() {
const text = message.content;
await clipboard.copy(text);
justCopied.value = true;
setTimeout(() => {
justCopied.value = false;
}, 1000);
}
const hoveredCodeBlockContent = computed(() => {
const idx = hoveredCodeBlockActions.value?.getAttribute('data-markdown-token-idx');
return idx ? markdown.codeBlockContents.value?.get(idx) : undefined;
});
function handleEdit() {
emit('startEdit');
@@ -133,14 +128,18 @@ function handleSwitchAlternative(messageId: ChatMessageId) {
emit('switchAlternative', messageId);
}
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
vueMarkdownItInstance.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
};
function handleMouseMove(e: MouseEvent | FocusEvent) {
const container =
e.target instanceof HTMLElement || e.target instanceof SVGElement
? e.target.closest('pre')?.querySelector(`.${styles.codeBlockActions}`)
: null;
hoveredCodeBlockActions.value = container instanceof HTMLElement ? container : null;
}
function handleMouseLeave() {
hoveredCodeBlockActions.value = null;
}
// Watch for isEditing prop changes to initialize edit mode
watch(
@@ -219,7 +218,11 @@ onBeforeMount(() => {
</div>
</div>
<div v-else>
<div :class="[$style.chatMessage, { [$style.errorMessage]: message.status === 'error' }]">
<div
:class="[$style.chatMessage, { [$style.errorMessage]: message.status === 'error' }]"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<div v-if="attachments.length > 0" :class="$style.attachments">
<ChatFile
v-for="(attachment, index) in attachments"
@@ -232,27 +235,25 @@ onBeforeMount(() => {
<div v-if="message.type === 'human'">{{ message.content }}</div>
<VueMarkdown
v-else
:key="forceReRenderKey"
:key="markdown.forceReRenderKey"
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
:source="
message.status === 'error' && !message.content
? i18n.baseText('chatHub.message.error.unknown')
: message.content
"
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
:options="markdown.options"
:plugins="markdown.plugins.value"
/>
</div>
<ChatTypingIndicator v-if="isStreaming" :class="$style.typingIndicator" />
<ChatMessageActions
v-else
:just-copied="justCopied"
:is-speech-synthesis-available="speech.isSupported.value"
:is-speaking="speech.isPlaying.value"
:class="$style.actions"
:message="message"
:alternatives="message.alternatives"
@copy="handleCopy"
@edit="handleEdit"
@regenerate="handleRegenerate"
@read-aloud="handleReadAloud"
@@ -260,6 +261,12 @@ onBeforeMount(() => {
/>
</div>
</div>
<Teleport
v-if="hoveredCodeBlockActions && hoveredCodeBlockContent"
:to="hoveredCodeBlockActions"
>
<CopyButton :content="hoveredCodeBlockContent" />
</Teleport>
</div>
</template>
@@ -400,6 +407,22 @@ onBeforeMount(() => {
padding: var(--chat--spacing);
background: var(--chat--message--pre--background);
border-radius: var(--chat--border-radius);
position: relative;
code:last-of-type {
padding-bottom: 0;
}
& .codeBlockActions {
position: absolute;
top: 0;
right: 0;
margin: var(--spacing--2xs);
}
& ~ pre {
margin-bottom: 1em;
}
}
table {
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { VIEWS } from '@/app/constants';
import type { ChatMessage } from '@/features/ai/chatHub/chat.types';
import CopyButton from '@/features/ai/chatHub/components/CopyButton.vue';
import type { ChatMessageId } from '@n8n/api-types';
import { N8nIconButton, N8nLink, N8nText, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@@ -10,8 +11,7 @@ import { useRouter } from 'vue-router';
const i18n = useI18n();
const router = useRouter();
const { justCopied, message, alternatives, isSpeaking, isSpeechSynthesisAvailable } = defineProps<{
justCopied: boolean;
const { message, alternatives, isSpeaking, isSpeechSynthesisAvailable } = defineProps<{
message: ChatMessage;
alternatives: ChatMessageId[];
isSpeechSynthesisAvailable: boolean;
@@ -19,17 +19,12 @@ const { justCopied, message, alternatives, isSpeaking, isSpeechSynthesisAvailabl
}>();
const emit = defineEmits<{
copy: [];
edit: [];
regenerate: [];
switchAlternative: [messageId: ChatMessageId];
readAloud: [];
}>();
const copyTooltip = computed(() => {
return justCopied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy');
});
const currentAlternativeIndex = computed(() => {
return alternatives.findIndex((id) => id === message.id);
});
@@ -44,10 +39,6 @@ const executionUrl = computed(() => {
return undefined;
});
function handleCopy() {
emit('copy');
}
function handleEdit() {
emit('edit');
}
@@ -63,16 +54,7 @@ function handleReadAloud() {
<template>
<div :class="$style.actions">
<N8nTooltip placement="bottom" :show-after="300">
<N8nIconButton
:icon="justCopied ? 'check' : 'copy'"
type="tertiary"
size="medium"
text
@click="handleCopy"
/>
<template #content>{{ copyTooltip }}</template>
</N8nTooltip>
<CopyButton :content="message.content" />
<N8nTooltip
v-if="isSpeechSynthesisAvailable && message.type === 'ai'"
placement="bottom"
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useClipboard } from '@/app/composables/useClipboard';
import { useI18n } from '@n8n/i18n';
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
const { content } = defineProps<{ content: string }>();
const i18n = useI18n();
const clipboard = useClipboard();
const justCopied = ref(false);
const copyTooltip = computed(() => {
return justCopied.value ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy');
});
async function handleCopy() {
await clipboard.copy(content);
justCopied.value = true;
setTimeout(() => {
justCopied.value = false;
}, 1000);
}
</script>
<template>
<N8nTooltip placement="bottom" :show-after="300">
<N8nIconButton
:icon="justCopied ? 'check' : 'copy'"
type="tertiary"
size="medium"
text
:class="$style.button"
tabindex="0"
@click="handleCopy"
/>
<template #content>{{ copyTooltip }}</template>
</N8nTooltip>
</template>
<style lang="scss" module>
.button {
& g,
& path {
color: var(--color--text--tint-1);
stroke-width: 2.5;
}
}
</style>
@@ -42,6 +42,8 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import { truncateBeforeLast } from '@n8n/utils';
const NEW_AGENT_MENU_ID = 'agent::new';
const MAX_AGENT_NAME_CHARS = 30;
const MAX_AGENT_NAME_CHARS_MENU = 45;
const {
selectedAgent,
@@ -97,15 +99,20 @@ const isCredentialsMissing = computed(
const menu = computed(() => {
const menuItems: (typeof N8nNavigationDropdown)['menu'] = [];
const fullNamesMap: Record<string, string> = {};
if (includeCustomAgents) {
const customAgents = isLoading.value
? []
: [...agents.value['custom-agent'].models, ...agents.value['n8n'].models].map((agent) => ({
id: stringifyModel(agent.model),
title: agent.name,
disabled: false,
}));
: [...agents.value['custom-agent'].models, ...agents.value['n8n'].models].map((agent) => {
const id = stringifyModel(agent.model);
fullNamesMap[id] = agent.name;
return {
id,
title: truncateBeforeLast(agent.name, MAX_AGENT_NAME_CHARS_MENU),
disabled: false,
};
});
menuItems.push({
id: 'custom-agents',
@@ -200,11 +207,16 @@ const menu = computed(() => {
(m) => 'model' in agent.model && m.model === agent.model.model,
),
)
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((agent) => ({
id: stringifyModel(agent.model),
title: agent.name,
disabled: false,
}))
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((agent) => {
const id = stringifyModel(agent.model);
fullNamesMap[id] = agent.name;
return {
id,
title: truncateBeforeLast(agent.name, MAX_AGENT_NAME_CHARS_MENU),
disabled: false,
};
})
.filter((item, index, self) => self.findIndex((i) => i.id === item.id) === index)
: error
? [{ id: `${provider}::error`, value: null, disabled: true, title: error }]
: [];
@@ -232,7 +244,7 @@ const menu = computed(() => {
});
}
return menuItems;
return { items: menuItems, fullNames: fullNamesMap };
});
const selectedLabel = computed(
@@ -347,7 +359,13 @@ defineExpose({
</script>
<template>
<N8nNavigationDropdown ref="dropdownRef" :menu="menu" teleport @select="onSelect">
<N8nNavigationDropdown
ref="dropdownRef"
:submenu-class="$style.component"
:menu="menu.items"
teleport
@select="onSelect"
>
<template #item-icon="{ item }">
<CredentialIcon
v-if="item.id in PROVIDER_CREDENTIAL_TYPE_MAP"
@@ -358,7 +376,7 @@ defineExpose({
<N8nAvatar
v-else-if="item.id.startsWith('n8n::') || item.id.startsWith('custom-agent::')"
:class="$style.avatarIcon"
:first-name="item.title"
:first-name="menu.fullNames[item.id] || item.title"
size="xsmall"
/>
</template>
@@ -372,10 +390,10 @@ defineExpose({
/>
<div :class="$style.selected">
<div>
{{ truncateBeforeLast(selectedLabel, 30) }}
{{ truncateBeforeLast(selectedLabel, MAX_AGENT_NAME_CHARS) }}
</div>
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
{{ truncateBeforeLast(credentialsName, 30) }}
{{ truncateBeforeLast(credentialsName, MAX_AGENT_NAME_CHARS) }}
</N8nText>
<N8nText v-else-if="isCredentialsMissing" size="xsmall" color="danger">
<N8nIcon
@@ -392,6 +410,13 @@ defineExpose({
</template>
<style lang="scss" module>
.component {
& :global(.el-popper) {
/* Enforce via text truncation instead */
max-width: unset !important;
}
}
.dropdownButton {
display: flex;
align-items: center;
@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
import { type HLJSApi } from 'highlight.js';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import type MarkdownIt from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
let hljsInstance: HLJSApi | undefined;
let asyncImport:
@@ -11,10 +13,11 @@ let asyncImport:
| { status: 'uninitialized' }
| { status: 'done' } = { status: 'uninitialized' };
export function useChatHubMarkdownOptions() {
export function useChatHubMarkdownOptions(codeBlockActionsClassName: string) {
const forceReRenderKey = ref(0);
const codeBlockContents = ref<Map<string, string>>();
const markdownOptions = {
const options = {
highlight(str: string, lang: string) {
if (!lang) {
return ''; // use external default escaping
@@ -65,5 +68,40 @@ export function useChatHubMarkdownOptions() {
}
}
return { markdownOptions, forceReRenderKey };
const plugins = computed(() => {
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
vueMarkdownItInstance.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
};
const codeBlockPlugin = (vueMarkdownItInstance: MarkdownIt) => {
const defaultFenceRenderer = vueMarkdownItInstance.renderer.rules.fence;
codeBlockContents.value = new Map();
vueMarkdownItInstance.renderer.rules.fence = (tokens, idx, options, env, self) => {
const defaultRendered =
defaultFenceRenderer?.(tokens, idx, options, env, self) ??
self.renderToken(tokens, idx, options);
const content = tokens[idx]?.content.trim();
if (content) {
codeBlockContents.value?.set(String(idx), content);
}
return defaultRendered.replace(
'</pre>',
`<div data-markdown-token-idx="${idx}" class="${codeBlockActionsClassName}"></div></pre>`,
);
};
};
return [linksNewTabPlugin, codeBlockPlugin];
});
return { options, forceReRenderKey, plugins, codeBlockContents };
}