fix: Chat UI feedback 2 (no-changelog) (#22109)

This commit is contained in:
Suguru Inoue 2025-11-24 09:24:51 +01:00 committed by GitHub
parent 6f2709608f
commit a8ccc9f53e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 128 additions and 77 deletions

View File

@ -135,7 +135,7 @@ watch(
behaviors
</N8nText>
</div>
<N8nButton icon="plus" type="primary" size="large" @click="handleCreateAgent">
<N8nButton icon="plus" type="primary" size="medium" @click="handleCreateAgent">
New Agent
</N8nButton>
</div>

View File

@ -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<ChatHubSendMessageRequest['crede
return null;
}
if (provider === 'custom-agent' || provider === 'n8n') {
if (!isLlmProvider(provider)) {
return {};
}
@ -331,6 +331,7 @@ function onSubmit(message: string, attachments: File[]) {
}
didSubmitInCurrentSession.value = true;
editingMessageId.value = undefined;
void chatStore.sendMessage(
sessionId.value,

View File

@ -48,7 +48,7 @@ import type {
} from './chat.types';
import { retry } from '@n8n/utils/retry';
import { convertFileToChatAttachment } from '@/app/utils/fileUtils';
import { buildUiMessages, isMatchedAgent } from './chat.utils';
import { buildUiMessages, isLlmProviderModel, isMatchedAgent } from './chat.utils';
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
@ -260,6 +260,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
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;
}

View File

@ -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);
}

View File

@ -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];

View File

@ -61,6 +61,7 @@ defineExpose({
ref="modelSelectorRef"
:selected-agent="selectedModel"
:credentials="credentials"
text
@change="onModelChange"
@create-custom-agent="emit('createCustomAgent')"
@select-credential="

View File

@ -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"
/>
<div :class="$style.editActions">
<N8nButton type="secondary" size="small" @click="handleCancelEdit"> Cancel </N8nButton>
@ -181,7 +199,7 @@ onBeforeMount(() => {
</N8nButton>
</div>
</div>
<template v-else>
<div v-else>
<div :class="[$style.chatMessage, { [$style.errorMessage]: message.status === 'error' }]">
<div v-if="attachments.length > 0" :class="$style.attachments">
<ChatFile
@ -192,7 +210,9 @@ onBeforeMount(() => {
:href="attachment.downloadUrl"
/>
</div>
<div v-if="message.type === 'human'">{{ message.content }}</div>
<VueMarkdown
v-else
:key="forceReRenderKey"
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
:source="
@ -219,7 +239,7 @@ onBeforeMount(() => {
@read-aloud="handleReadAloud"
@switchAlternative="handleSwitchAlternative"
/>
</template>
</div>
</div>
</div>
</template>
@ -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);

View File

@ -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<HTMLElement>('inputRef');
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef');
const message = ref('');
const committedSpokenMessage = ref('');
const attachments = ref<File[]>([]);
const toast = useToast();
@ -44,12 +46,12 @@ const placeholder = computed(() =>
);
const llmProvider = computed<ChatHubLLMProvider | undefined>(() =>
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({
<N8nText v-else-if="isMissingCredentials && llmProvider" :class="$style.callout">
<template v-if="isNewSession">
Please
<a href="" @click.prevent="emit('setCredentials', llmProvider)"> set credentials </a>
<a href="" @click.prevent="emit('setCredentials', llmProvider)">set credentials</a>
for {{ providerDisplayNames[llmProvider] }} to start a conversation
</template>
<template v-else>
Please
<a href="" @click.prevent="emit('setCredentials', llmProvider)"> set credentials </a>
<a href="" @click.prevent="emit('setCredentials', llmProvider)">set credentials</a>
for {{ providerDisplayNames[llmProvider] }} to continue the conversation
</template>
</N8nText>
@ -217,8 +229,10 @@ defineExpose({
<div :class="$style.footer">
<div v-if="isToolsSelectable" :class="$style.tools">
<ToolsSelector
:class="$style.toolsButton"
:selected="selectedTools ?? []"
:disabled="isMissingCredentials || !selectedModel || isResponding"
transparent-bg
@select="onSelectTools"
/>
</div>
@ -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 {

View File

@ -88,7 +88,7 @@ function handleKeyDown(e: KeyboardEvent) {
return;
}
if (e.key === 'Enter') {
if (e.key === 'Enter' && !e.isComposing) {
handleBlur();
}
}

View File

@ -33,7 +33,12 @@ defineSlots<{
<div :class="[$style.menuItem, { [$style.active]: active }]">
<slot v-if="$slots.default" />
<template v-else>
<RouterLink :to="to" :class="$style.menuItemLink" @click="emit('click', $event)">
<RouterLink
:to="to"
:class="$style.menuItemLink"
:title="label"
@click="emit('click', $event)"
>
<slot name="icon">
<N8nIcon v-if="icon" size="large" :icon="icon" />
</slot>

View File

@ -29,6 +29,7 @@ import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vu
import {
flattenModel,
fromStringToModel,
isLlmProviderModel,
isMatchedAgent,
stringifyModel,
} from '@/features/ai/chatHub/chat.utils';
@ -43,10 +44,12 @@ const {
selectedAgent,
includeCustomAgents = true,
credentials,
text,
} = defineProps<{
selectedAgent: ChatModelDto | null;
includeCustomAgents?: boolean;
credentials: CredentialsMap | null;
text?: boolean;
}>();
const emit = defineEmits<{
@ -86,12 +89,7 @@ const credentialsName = computed(() =>
? credentialsStore.getCredentialById(credentials?.[selectedAgent.model.provider] ?? '')?.name
: undefined,
);
const isCredentialsRequired = computed(
() =>
selectedAgent &&
selectedAgent.model.provider !== 'n8n' &&
selectedAgent.model.provider !== 'custom-agent',
);
const isCredentialsRequired = computed(() => isLlmProviderModel(selectedAgent?.model));
const menu = computed(() => {
const menuItems: (typeof N8nNavigationDropdown)['menu'] = [];
@ -153,7 +151,6 @@ const menu = computed(() => {
const agentOptions =
theAgents.length > 0
? theAgents
.filter((agent) => agent.model.provider !== 'custom-agent')
.filter(
(agent) =>
agent.model.provider === 'n8n' ||
@ -250,20 +247,12 @@ function onSelect(id: string) {
return;
}
if (
identifier === 'configure' &&
parsedModel.provider !== 'n8n' &&
parsedModel.provider !== 'custom-agent'
) {
if (identifier === 'configure' && isLlmProviderModel(parsedModel)) {
openCredentialsSelectorOrCreate(parsedModel.provider);
return;
}
if (
identifier === 'add-model' &&
parsedModel.provider !== 'n8n' &&
parsedModel.provider !== 'custom-agent'
) {
if (identifier === 'add-model' && isLlmProviderModel(parsedModel)) {
openModelByIdSelector(parsedModel.provider);
return;
}
@ -335,7 +324,7 @@ defineExpose({
/>
</template>
<N8nButton :class="$style.dropdownButton" type="secondary" text>
<N8nButton :class="$style.dropdownButton" type="secondary" :text="text">
<ChatAgentAvatar
v-if="selectedAgent"
:agent="selectedAgent"

View File

@ -7,9 +7,10 @@ import type { INode } from 'n8n-workflow';
import { computed, onMounted } from 'vue';
import { TOOLS_SELECTOR_MODAL_KEY } from '../constants';
const { selected } = defineProps<{
const { selected, transparentBg = false } = defineProps<{
disabled: boolean;
selected: INode[];
transparentBg?: boolean;
}>();
const emit = defineEmits<{
@ -51,7 +52,8 @@ const onClick = () => {
<template>
<N8nButton
type="secondary"
:class="$style.toolsButton"
native-type="button"
:class="[$style.toolsButton, { [$style.transparentBg]: transparentBg }]"
:disabled="disabled"
aria-label="Select tools"
:icon="toolCount > 0 ? undefined : 'plus'"
@ -77,8 +79,10 @@ const onClick = () => {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--4xs) var(--spacing--2xs);
background-color: transparent;
&.transparentBg {
background-color: transparent;
}
}
.iconStack {

View File

@ -9,6 +9,7 @@ import {
} from '@n8n/api-types';
import { useLocalStorage } from '@vueuse/core';
import { computed, onMounted, ref } from 'vue';
import { isLlmProvider } from '../chat.utils';
/**
* Composable for managing chat credentials including auto-selection and user selection.
@ -37,10 +38,14 @@ export function useChatCredentials(userId: string) {
},
);
const isCredentialsReady = computed(
() => isInitialized.value || credentialsStore.allCredentials.length > 0,
);
const autoSelectCredentials = computed<CredentialsMap>(() =>
Object.fromEntries(
chatHubProviderSchema.options.map((provider) => {
if (provider === 'n8n' || provider === 'custom-agent') {
if (!isLlmProvider(provider)) {
return [provider, null];
}
@ -73,7 +78,7 @@ export function useChatCredentials(userId: string) {
);
const credentialsByProvider = computed<CredentialsMap | null>(() =>
isInitialized.value
isCredentialsReady.value
? {
...autoSelectCredentials.value,
...selectedCredentials.value,