diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue index 2ea10cd87f6..31df450f445 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue @@ -135,7 +135,7 @@ watch( behaviors - + New Agent diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue index 4971fa908db..4a4fd39d7d1 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue @@ -5,7 +5,11 @@ import { LOCAL_STORAGE_CHAT_HUB_SELECTED_TOOLS, VIEWS, } from '@/app/constants'; -import { findOneFromModelsResponse, unflattenModel } from '@/features/ai/chatHub/chat.utils'; +import { + findOneFromModelsResponse, + isLlmProvider, + unflattenModel, +} from '@/features/ai/chatHub/chat.utils'; import ChatConversationHeader from '@/features/ai/chatHub/components/ChatConversationHeader.vue'; import ChatMessage from '@/features/ai/chatHub/components/ChatMessage.vue'; import ChatPrompt from '@/features/ai/chatHub/components/ChatPrompt.vue'; @@ -67,11 +71,7 @@ const currentConversationTitle = computed(() => currentConversation.value?.title const readyToShowMessages = computed(() => chatStore.agentsReady); // TODO: This also depends on the model, not all base LLM models support tools. -const canSelectTools = computed( - () => - selectedModel.value?.model.provider !== 'custom-agent' && - selectedModel.value?.model.provider !== 'n8n', -); +const canSelectTools = computed(() => isLlmProvider(selectedModel.value?.model.provider)); const { arrivedState, measure } = useScroll(scrollContainerRef, { throttle: 100, @@ -186,7 +186,7 @@ const credentialsForSelectedProvider = computed { sessionId: ChatSessionId, messageId: ChatMessageId, status: ChatHubMessageStatus, + content?: string, ) { const conversation = ensureConversation(sessionId); const message = conversation.messages[messageId]; @@ -268,6 +269,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => { } message.status = status; + if (content) { + message.content = content; + } message.updatedAt = new Date().toISOString(); } @@ -419,8 +423,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { return; } - updateMessage(sessionId, chunk.metadata.messageId, 'error'); - onChunk(message.content ?? ''); + updateMessage(sessionId, chunk.metadata.messageId, 'error', chunk.content); break; } } @@ -498,7 +501,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { name: 'User', content: message, provider: null, - model: model.provider === 'n8n' || model.provider === 'custom-agent' ? null : model.model, + model: isLlmProviderModel(model) ? model.model : null, workflowId: null, executionId: null, agentId: null, @@ -785,10 +788,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => { function getAgent(model: ChatHubConversationModel) { if (!agents.value) return null; - const agent = agents.value[model.provider].models.find((agent) => isMatchedAgent(agent, model)); + const agent = agents.value[model.provider]?.models.find((agent) => + isMatchedAgent(agent, model), + ); if (!agent) { - if (model.provider === 'custom-agent' || model.provider === 'n8n') { + if (!isLlmProviderModel(model)) { return null; } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts index 0ce2201f57a..ef37e112885 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts @@ -6,6 +6,8 @@ import { type ChatModelDto, type ChatSessionId, type ChatMessageId, + type ChatHubProvider, + type ChatHubLLMProvider, } from '@n8n/api-types'; import type { ChatMessage, @@ -320,3 +322,13 @@ export function buildUiMessages( return messagesToShow; } + +export function isLlmProvider(provider?: ChatHubProvider): provider is ChatHubLLMProvider { + return provider !== 'n8n' && provider !== 'custom-agent'; +} + +export function isLlmProviderModel( + model?: ChatHubConversationModel, +): model is ChatHubConversationModel & { provider: ChatHubLLMProvider } { + return isLlmProvider(model?.provider); +} 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 10e36581ac1..ccc43500763 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 @@ -13,6 +13,7 @@ import { computed, onMounted, ref } from 'vue'; import type { CredentialsMap } from '../chat.types'; import type { INode } from 'n8n-workflow'; import ToolsSelector from './ToolsSelector.vue'; +import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils'; const props = defineProps<{ modalName: string; @@ -109,8 +110,7 @@ async function onSave() { const model = 'model' in selectedModel.value ? selectedModel.value.model : undefined; - assert(model); - assert(model.provider !== 'n8n' && model.provider !== 'custom-agent'); + assert(isLlmProviderModel(model)); const credentialId = agentMergedCredentials.value[model.provider]; diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue index f2051312e21..c85dcfe2a4d 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue @@ -61,6 +61,7 @@ defineExpose({ ref="modelSelectorRef" :selected-agent="selectedModel" :credentials="credentials" + text @change="onModelChange" @create-custom-agent="emit('createCustomAgent')" @select-credential=" diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.vue index 6eab2e15e91..09ef90b6ed1 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatMessage.vue @@ -8,7 +8,7 @@ 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, nextTick, onBeforeMount, ref, useTemplateRef, watch } from 'vue'; +import { computed, onBeforeMount, ref, useTemplateRef, watch } from 'vue'; import VueMarkdown from 'vue-markdown-render'; import type { ChatMessage } from '../chat.types'; import ChatMessageActions from './ChatMessageActions.vue'; @@ -17,6 +17,7 @@ import { useChatStore } from '@/features/ai/chatHub/chat.store'; import ChatFile from '@n8n/chat/components/ChatFile.vue'; import { buildChatAttachmentUrl } from '@/features/ai/chatHub/chat.api'; import { useRootStore } from '@n8n/stores/useRootStore'; +import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; const { message, compact, isEditing, isStreaming, minHeight } = defineProps<{ message: ChatMessage; @@ -40,6 +41,7 @@ const emit = defineEmits<{ const clipboard = useClipboard(); const chatStore = useChatStore(); const rootStore = useRootStore(); +const { isCtrlKeyPressed } = useDeviceSupport(); const editedText = ref(''); const textareaRef = useTemplateRef('textarea'); @@ -96,6 +98,15 @@ function handleConfirmEdit() { emit('update', { ...message, content: editedText.value }); } +function handleKeydownTextarea(e: KeyboardEvent) { + const trimmed = editedText.value.trim(); + + if (e.key === 'Enter' && isCtrlKeyPressed(e) && !e.isComposing && trimmed) { + e.preventDefault(); + handleConfirmEdit(); + } +} + function handleRegenerate() { emit('regenerate', message); } @@ -126,18 +137,24 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => { // Watch for isEditing prop changes to initialize edit mode watch( () => isEditing, - async (editing) => { - if (editing) { - editedText.value = message.content; - await nextTick(); - textareaRef.value?.focus(); - } else { - editedText.value = ''; - } + (editing) => { + editedText.value = editing ? message.content : ''; }, { immediate: true }, ); +watch( + textareaRef, + async (textarea) => { + if (textarea) { + await new Promise((r) => setTimeout(r, 0)); + textarea.focus(); + textarea.$el.scrollIntoView({ block: 'nearest' }); + } + }, + { immediate: true, flush: 'post' }, +); + onBeforeMount(() => { speech.stop(); }); @@ -168,6 +185,7 @@ onBeforeMount(() => { type="textarea" :autosize="{ minRows: 3, maxRows: 20 }" :class="$style.textarea" + @keydown="handleKeydownTextarea" />
Cancel @@ -181,7 +199,7 @@ onBeforeMount(() => {
- + @@ -267,11 +287,14 @@ onBeforeMount(() => { gap: var(--spacing--2xs); position: relative; max-width: fit-content; + overflow-wrap: break-word; .user & { - padding: var(--spacing--3xs) var(--spacing--sm); + padding: var(--spacing--2xs) var(--spacing--sm); border-radius: var(--radius--xl); background-color: var(--color--background); + white-space-collapse: preserve-breaks; + line-height: 1.8; } } @@ -389,6 +412,10 @@ onBeforeMount(() => { gap: var(--spacing--2xs); } +.textarea { + scroll-margin-block: var(--spacing--sm); +} + .textarea textarea { font-family: inherit; background-color: var(--color--background--light-3); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue index d246c9ca21b..03ade655cbd 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue @@ -8,6 +8,7 @@ import { useSpeechRecognition } from '@vueuse/core'; import type { INode } from 'n8n-workflow'; import { computed, ref, useTemplateRef, watch } from 'vue'; import ToolsSelector from './ToolsSelector.vue'; +import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils'; const { selectedModel, selectedTools, isMissingCredentials } = defineProps<{ isResponding: boolean; @@ -29,6 +30,7 @@ const emit = defineEmits<{ const inputRef = useTemplateRef('inputRef'); const fileInputRef = useTemplateRef('fileInputRef'); const message = ref(''); +const committedSpokenMessage = ref(''); const attachments = ref([]); const toast = useToast(); @@ -44,12 +46,12 @@ const placeholder = computed(() => ); const llmProvider = computed(() => - selectedModel?.model.provider === 'n8n' || selectedModel?.model.provider === 'custom-agent' - ? undefined - : selectedModel?.model.provider, + isLlmProviderModel(selectedModel?.model) ? selectedModel?.model.provider : undefined, ); function onMic() { + committedSpokenMessage.value = message.value; + if (speechInput.isListening.value) { speechInput.stop(); } else { @@ -97,6 +99,7 @@ function handleSubmitForm() { speechInput.stop(); emit('submit', trimmed, attachments.value); message.value = ''; + committedSpokenMessage.value = ''; attachments.value = []; } } @@ -109,6 +112,7 @@ function handleKeydownTextarea(e: KeyboardEvent) { speechInput.stop(); emit('submit', trimmed, attachments.value); message.value = ''; + committedSpokenMessage.value = ''; attachments.value = []; } } @@ -118,11 +122,19 @@ function handleClickInputWrapper() { } watch(speechInput.result, (spoken) => { - if (spoken) { - message.value = spoken; - } + message.value = committedSpokenMessage.value + ' ' + spoken.trimStart(); }); +watch( + speechInput.isFinal, + (final) => { + if (final) { + committedSpokenMessage.value = message.value; + } + }, + { flush: 'post' }, +); + watch(speechInput.error, (event) => { if (event?.error === 'not-allowed') { toast.showError( @@ -172,12 +184,12 @@ defineExpose({ @@ -217,8 +229,10 @@ defineExpose({
@@ -315,6 +329,7 @@ defineExpose({ display: flex; flex-direction: column; gap: var(--spacing--sm); + transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); &:focus-within, &:hover { @@ -343,21 +358,8 @@ defineExpose({ } .toolsButton { - display: flex; - align-items: center; - gap: var(--spacing--2xs); - padding: var(--spacing--3xs) var(--spacing--xs); - color: var(--color--text); - cursor: pointer; - - border-radius: var(--radius); - border: var(--border); - background: var(--color--background--light-3); - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } + /* maintain the same height with other buttons regardless of selected tools */ + height: 30px; } .iconStack { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSessionMenuItem.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSessionMenuItem.vue index e71a8b259c4..59f24de0ff8 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSessionMenuItem.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSessionMenuItem.vue @@ -88,7 +88,7 @@ function handleKeyDown(e: KeyboardEvent) { return; } - if (e.key === 'Enter') { + if (e.key === 'Enter' && !e.isComposing) { handleBlur(); } } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarLink.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarLink.vue index c23b4a6c1fc..df3f1a05791 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarLink.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarLink.vue @@ -33,7 +33,12 @@ defineSlots<{
- + (); const emit = defineEmits<{ @@ -51,7 +52,8 @@ const onClick = () => {