diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index b8a2f58bf7b..701c68e048f 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -33,6 +33,7 @@ defineProps<{ menu: Array; disabled?: boolean; teleport?: boolean; + submenuClass?: string; }>(); const menuRef = ref(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" > diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue index c9f04b4a3a1..77854c8e4de 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue @@ -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(null); const isSaving = ref(false); const isDeleting = ref(false); +const isOpened = ref(false); const tools = ref([]); +const nameInputRef = useTemplateRef('nameInput'); const agentSelectedCredentials = ref({}); 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" > -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(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(() => {
-
+
{
{{ message.content }}
{ />
+ + +
@@ -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 { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessageActions.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessageActions.vue index cdbd6e0704f..7a1dc9aa6bc 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessageActions.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessageActions.vue @@ -1,6 +1,7 @@ + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ModelSelector.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ModelSelector.vue index 2f8751a8b52..df64e8f21f5 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ModelSelector.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ModelSelector.vue @@ -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 = {}; 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['menu'][number]>((agent) => ({ - id: stringifyModel(agent.model), - title: agent.name, - disabled: false, - })) + .map['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({