feat(editor): Show callout when credentials are not set (no-changelog) (#20997)

This commit is contained in:
Suguru Inoue 2025-10-22 12:44:01 +02:00 committed by GitHub
parent 7681383e73
commit af1096b3e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 161 additions and 129 deletions

View File

@ -45,6 +45,10 @@ defineSlots<{
[key: `item.append.${string}`]: (props: { item: Item }) => unknown;
}>();
const open = () => {
menuRef.value?.open(ROOT_MENU_INDEX);
};
const close = () => {
menuRef.value?.close(ROOT_MENU_INDEX);
};
@ -61,6 +65,7 @@ const onClose = (index: string) => {
};
defineExpose({
open,
close,
});
</script>

View File

@ -33,6 +33,8 @@ import { useRoute, useRouter } from 'vue-router';
import { useChatStore } from './chat.store';
import { credentialsMapSchema, type CredentialsMap } from './chat.types';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useUIStore } from '@/stores/ui.store';
import CredentialSelectorModal from '@/features/ai/chatHub/components/CredentialSelectorModal.vue';
const router = useRouter();
const route = useRoute();
@ -42,7 +44,9 @@ const credentialsStore = useCredentialsStore();
const toast = useToast();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const documentTitle = useDocumentTitle();
const uiStore = useUIStore();
const headerRef = useTemplateRef('headerRef');
const inputRef = useTemplateRef('inputRef');
const sessionId = computed<string>(() =>
typeof route.params.id === 'string' ? route.params.id : uuidv4(),
@ -117,18 +121,17 @@ const mergedCredentials = computed(() => ({
const chatMessages = computed(() => chatStore.getActiveMessages(sessionId.value));
const isNewChat = computed(() => route.name === CHAT_VIEW);
const inputPlaceholder = computed(() => {
if (!selectedModel.value) {
return 'Select a model';
}
const modelName = selectedModel.value.model;
return `Message ${modelName}`;
});
const credentialsId = computed(() =>
selectedModel.value ? mergedCredentials.value[selectedModel.value.provider] : undefined,
);
const editingMessageId = ref<string>();
const didSubmitInCurrentSession = ref(false);
const initialization = ref({ credentialsFetched: false, modelsFetched: false });
const credentialSelectorProvider = ref<ChatHubProvider | null>(null);
const isInitialized = computed(
() => initialization.value.credentialsFetched && initialization.value.modelsFetched,
);
function scrollToBottom(smooth: boolean) {
scrollContainerRef.value?.scrollTo({
@ -179,6 +182,8 @@ watch(
if (selected === null) {
selectedModel.value = findOneFromModelsResponse(models) ?? null;
}
initialization.value.modelsFetched = true;
},
{ immediate: true },
);
@ -215,32 +220,22 @@ onMounted(async () => {
credentialsStore.fetchCredentialTypes(false),
credentialsStore.fetchAllCredentials(),
]);
initialization.value.credentialsFetched = true;
});
function onSubmit(message: string) {
if (!message.trim() || chatStore.isResponding) {
if (!message.trim() || chatStore.isResponding || !selectedModel.value || !credentialsId.value) {
return;
}
const credentialsId = selectedModel.value
? mergedCredentials.value[selectedModel.value.provider]
: undefined;
didSubmitInCurrentSession.value = true;
chatStore.sendMessage(
sessionId.value,
message,
selectedModel.value,
selectedModel.value && credentialsId
? {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId,
name: '',
},
}
: null,
);
chatStore.sendMessage(sessionId.value, message, selectedModel.value, {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId.value,
name: '',
},
});
inputRef.value?.setText('');
@ -263,21 +258,20 @@ function handleCancelEditMessage() {
}
function handleEditMessage(message: ChatHubMessageDto) {
if (chatStore.isResponding || !['human', 'ai'].includes(message.type) || !selectedModel.value) {
if (
chatStore.isResponding ||
!['human', 'ai'].includes(message.type) ||
!selectedModel.value ||
!credentialsId.value
) {
return;
}
const credentialsId = mergedCredentials.value[selectedModel.value.provider];
const messageToEdit = message.revisionOfMessageId ?? message.id;
if (!credentialsId) {
return;
}
const mesasgeToEdit = message.revisionOfMessageId ?? message.id;
chatStore.editMessage(sessionId.value, mesasgeToEdit, message.content, selectedModel.value, {
chatStore.editMessage(sessionId.value, messageToEdit, message.content, selectedModel.value, {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId,
id: credentialsId.value,
name: '',
},
});
@ -285,13 +279,12 @@ function handleEditMessage(message: ChatHubMessageDto) {
}
function handleRegenerateMessage(message: ChatHubMessageDto) {
if (chatStore.isResponding || message.type !== 'ai' || !selectedModel.value) {
return;
}
const credentialsId = mergedCredentials.value[selectedModel.value.provider];
if (!credentialsId) {
if (
chatStore.isResponding ||
message.type !== 'ai' ||
!selectedModel.value ||
!credentialsId.value
) {
return;
}
@ -299,7 +292,7 @@ function handleRegenerateMessage(message: ChatHubMessageDto) {
chatStore.regenerateMessage(sessionId.value, messageToRetry, selectedModel.value, {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId,
id: credentialsId.value,
name: '',
},
});
@ -316,6 +309,27 @@ function handleSelectCredentials(provider: ChatHubProvider, credentialsId: strin
function handleSwitchAlternative(messageId: string) {
chatStore.switchAlternative(sessionId.value, messageId);
}
function handleConfigureCredentials(provider: ChatHubProvider) {
const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
const existingCredentials = credentialsStore.getCredentialsByType(credentialType);
if (existingCredentials.length === 0) {
uiStore.openNewCredential(credentialType);
return;
}
credentialSelectorProvider.value = provider;
uiStore.openModal('chatCredentialSelector');
}
function handleConfigureModel() {
headerRef.value?.openModelSelector();
}
function handleCreateNewCredential(provider: ChatHubProvider) {
uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]);
}
</script>
<template>
@ -329,13 +343,25 @@ function handleSwitchAlternative(messageId: string) {
]"
>
<ChatConversationHeader
v-if="isInitialized"
ref="headerRef"
:selected-model="selectedModel"
:credentials="mergedCredentials"
@select-model="handleSelectModel"
@select-credentials="handleSelectCredentials"
@set-credentials="handleConfigureCredentials"
/>
<CredentialSelectorModal
v-if="credentialSelectorProvider"
:key="credentialSelectorProvider"
:provider="credentialSelectorProvider"
:initial-value="mergedCredentials[credentialSelectorProvider] ?? null"
@select="handleSelectCredentials"
@create-new="handleCreateNewCredential"
/>
<N8nScrollArea
v-if="isInitialized"
type="scroll"
:enable-vertical-scroll="true"
:enable-horizontal-scroll="false"
@ -380,13 +406,16 @@ function handleSwitchAlternative(messageId: string) {
/>
<ChatPrompt
v-if="isInitialized"
ref="inputRef"
:class="$style.prompt"
:placeholder="inputPlaceholder"
:is-responding="chatStore.isResponding"
:disabled="chatStore.isResponding"
:selected-model="selectedModel"
:is-credentials-selected="!!credentialsId"
@submit="onSubmit"
@stop="onStop"
@select-model="handleConfigureModel"
@set-credentials="handleConfigureCredentials"
/>
</div>
</div>

View File

@ -334,8 +334,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
function sendMessage(
sessionId: ChatSessionId,
message: string,
model: ChatHubConversationModel | null,
credentials: ChatHubSendMessageRequest['credentials'] | null,
model: ChatHubConversationModel,
credentials: ChatHubSendMessageRequest['credentials'],
) {
const messageId = uuidv4();
const replyId = uuidv4();
@ -364,29 +364,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
alternatives: [],
});
if (!model || !credentials) {
addMessage(sessionId, {
id: replyId,
sessionId,
type: 'ai',
name: 'AI',
content: '**ERROR:** Select a model to start a conversation.',
provider: null,
model: null,
workflowId: null,
executionId: null,
status: 'error',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
previousMessageId: messageId,
retryOfMessageId: null,
revisionOfMessageId: null,
responses: [],
alternatives: [],
});
return;
}
sendMessageApi(
rootStore.restApiContext,
{

View File

@ -1,20 +1,17 @@
<script setup lang="ts">
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import type { CredentialsMap } from '@/features/ai/chatHub/chat.types';
import CredentialSelectorModal from '@/features/ai/chatHub/components/CredentialSelectorModal.vue';
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useUIStore } from '@/stores/ui.store';
import {
type ChatHubConversationModel,
type ChatHubProvider,
type ChatSessionId,
PROVIDER_CREDENTIAL_TYPE_MAP,
} from '@n8n/api-types';
import { N8nIconButton } from '@n8n/design-system';
import { computed, ref } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
const { selectedModel, credentials } = defineProps<{
@ -24,17 +21,15 @@ const { selectedModel, credentials } = defineProps<{
const emit = defineEmits<{
selectModel: [ChatHubConversationModel];
selectCredentials: [provider: ChatHubProvider, credentialsId: string];
setCredentials: [provider: ChatHubProvider];
renameConversation: [id: ChatSessionId, title: string];
}>();
const sidebar = useChatHubSidebarState();
const chatStore = useChatStore();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const router = useRouter();
const credentialSelectorProvider = ref<ChatHubProvider | null>(null);
const modelSelectorRef = useTemplateRef('modelSelectorRef');
const credentialsName = computed(() =>
selectedModel
@ -46,32 +41,15 @@ function onModelChange(selection: ChatHubConversationModel) {
emit('selectModel', selection);
}
function onConfigure(provider: ChatHubProvider) {
const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
const existingCredentials = credentialsStore.getCredentialsByType(credentialType);
if (existingCredentials.length === 0) {
uiStore.openNewCredential(credentialType);
return;
}
credentialSelectorProvider.value = provider;
uiStore.openModal('chatCredentialSelector');
}
function onNewChat() {
sidebar.toggleOpen(false);
void router.push({ name: CHAT_VIEW, force: true });
}
function onCredentialSelected(provider: ChatHubProvider, credentialsId: string) {
emit('selectCredentials', provider, credentialsId);
}
function onCreateNewCredential(provider: ChatHubProvider) {
uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]);
}
defineExpose({
openModelSelector: () => modelSelectorRef.value?.open(),
});
</script>
<template>
@ -95,20 +73,12 @@ function onCreateNewCredential(provider: ChatHubProvider) {
@click="onNewChat"
/>
<ModelSelector
ref="modelSelectorRef"
:models="chatStore.models ?? null"
:selected-model="selectedModel"
:credentials-name="credentialsName"
@change="onModelChange"
@configure="onConfigure"
/>
<CredentialSelectorModal
v-if="credentialSelectorProvider"
:key="credentialSelectorProvider"
:provider="credentialSelectorProvider"
:initial-value="credentials[credentialSelectorProvider] ?? null"
@select="onCredentialSelected"
@create-new="onCreateNewCredential"
@configure="emit('setCredentials', $event)"
/>
</div>
</template>

View File

@ -1,18 +1,23 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import type { ChatHubConversationModel, ChatHubProvider } from '@n8n/api-types';
import { N8nIconButton, N8nInput } from '@n8n/design-system';
import { useSpeechRecognition } from '@vueuse/core';
import { computed } from 'vue';
import { ref, useTemplateRef, watch } from 'vue';
const { disabled } = defineProps<{
placeholder: string;
disabled: boolean;
const { selectedModel } = defineProps<{
isResponding: boolean;
selectedModel: ChatHubConversationModel | null;
isCredentialsSelected: boolean;
}>();
const emit = defineEmits<{
submit: [string];
stop: [];
selectModel: [];
setCredentials: [ChatHubProvider];
}>();
const inputRef = useTemplateRef('inputRef');
@ -26,6 +31,16 @@ const speechInput = useSpeechRecognition({
lang: navigator.language,
});
const placeholder = computed(() => {
if (!selectedModel) {
return 'Select a model';
}
const modelName = selectedModel.model;
return `Message ${modelName}`;
});
function onMic() {
if (speechInput.isListening.value) {
speechInput.stop();
@ -91,6 +106,17 @@ defineExpose({
<template>
<form :class="$style.prompt" @submit.prevent="handleSubmitForm">
<div :class="$style.inputWrap">
<div v-if="!selectedModel" :class="$style.callout">
Please <a href="" @click.prevent="emit('selectModel')">select a model</a> to start a
conversation
</div>
<div v-else-if="!isCredentialsSelected" :class="$style.callout">
Please
<a href="" @click.prevent="emit('setCredentials', selectedModel.provider)">
set credentials
</a>
for {{ providerDisplayNames[selectedModel.provider] }} to start a conversation
</div>
<N8nInput
ref="inputRef"
v-model="message"
@ -100,6 +126,7 @@ defineExpose({
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="!isCredentialsSelected || !selectedModel"
@keydown="handleKeydownTextarea"
/>
@ -109,7 +136,7 @@ defineExpose({
native-type="button"
type="secondary"
title="Attach"
:disabled="disabled"
:disabled="!isCredentialsSelected || !selectedModel || isResponding"
icon="paperclip"
icon-size="large"
text
@ -120,7 +147,7 @@ defineExpose({
native-type="button"
:title="speechInput.isListening.value ? 'Stop recording' : 'Voice input'"
type="secondary"
:disabled="disabled"
:disabled="!isCredentialsSelected || !selectedModel || isResponding"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
@ -129,7 +156,7 @@ defineExpose({
<N8nIconButton
v-if="!isResponding"
native-type="submit"
:disabled="disabled || !message.trim()"
:disabled="!isCredentialsSelected || !selectedModel || !message.trim()"
title="Send"
icon="arrow-up"
icon-size="large"
@ -157,9 +184,28 @@ defineExpose({
position: relative;
display: flex;
align-items: center;
flex-direction: column;
width: 100%;
}
.callout {
color: var(--color--secondary);
background-color: hsla(247, 49%, 53%, 0.1);
padding: 16px 16px 32px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
width: 100%;
border: var(--border);
border-color: var(--color--secondary);
text-align: center;
margin-bottom: -16px;
& a {
text-decoration: underline;
color: inherit;
}
}
.input {
& textarea {
font: inherit;

View File

@ -1,14 +1,11 @@
<script setup lang="ts">
import { useUsersStore } from '@/features/settings/users/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { N8nHeading } from '@n8n/design-system';
import Logo from '@n8n/design-system/components/N8nLogo/Logo.vue';
import { computed } from 'vue';
defineProps<{ isMobileDevice: boolean }>();
const userStore = useUsersStore();
const settingsStore = useSettingsStore();
const greetings = computed(
() => `Hello, ${userStore.currentUser?.firstName ?? userStore.currentUser?.fullName ?? 'User'}!`,
@ -17,13 +14,9 @@ const greetings = computed(
<template>
<div :class="[$style.starter, { [$style.isMobileDevice]: isMobileDevice }]">
<Logo size="large" collapsed :release-channel="settingsStore.settings.releaseChannel" />
<div :class="$style.header">
<N8nHeading tag="h2" bold size="xlarge">
{{ greetings }}
</N8nHeading>
</div>
<N8nHeading tag="h2" bold size="xlarge">
{{ greetings }}
</N8nHeading>
</div>
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { N8nNavigationDropdown, N8nIcon, N8nButton, N8nText } from '@n8n/design-system';
import { type ComponentProps } from 'vue-component-type-helpers';
import {
@ -11,6 +11,7 @@ import {
} from '@n8n/api-types';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { onClickOutside } from '@vueuse/core';
const props = defineProps<{
models: ChatModelsResponse | null;
@ -23,6 +24,8 @@ const emit = defineEmits<{
configure: [ChatHubProvider];
}>();
const dropdownRef = useTemplateRef('dropdownRef');
const menu = computed(() =>
chatHubProviderSchema.options.map((provider) => {
const models = props.models?.[provider].models ?? [];
@ -76,10 +79,19 @@ function onSelect(id: string) {
emit('change', { provider: parsedProvider, model, workflowId: null });
}
onClickOutside(
computed(() => dropdownRef.value?.$el),
() => dropdownRef.value?.close(),
);
defineExpose({
open: () => dropdownRef.value?.open(),
});
</script>
<template>
<N8nNavigationDropdown :menu="menu" @select="onSelect">
<N8nNavigationDropdown ref="dropdownRef" :menu="menu" @select="onSelect">
<template #item-icon="{ item }">
<CredentialIcon
v-if="item.id in PROVIDER_CREDENTIAL_TYPE_MAP"