diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e39c4c4cf09..f7b700fd1e5 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -303,6 +303,7 @@ "chatHub.agent.newAgent": "New Agent", "chatHub.agent.configureCredentials": "Configure credentials", "chatHub.agent.addModel": "Add model", + "chatHub.agent.credentialsMissing": "Credentials missing", "chatHub.agent.card.menu.edit": "Edit", "chatHub.agent.card.menu.delete": "Delete", "chatHub.agent.card.noDescription": "No description", @@ -388,12 +389,12 @@ "chatHub.tools.editor.cancel": "Cancel", "chatHub.tools.selector.label.count": "{count} Tool | {count} Tools", "chatHub.tools.selector.label.default": "Tools", - "chatHub.credentials.selector.placeholder": "Select credential...", "chatHub.credentials.selector.title": "Select {provider} credential", "chatHub.credentials.selector.chooseOrCreate": "Choose or create a credential for {provider}", "chatHub.credentials.selector.createNew": "Create new", "chatHub.credentials.selector.confirm": "Select", "chatHub.credentials.selector.cancel": "Cancel", + "chatHub.credentials.selector.deleteButton": "Delete credential", "chatHub.message.actions.readAloud": "Read aloud", "chatHub.message.actions.stopReading": "Stop reading", "chatHub.message.actions.edit": "Edit", 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 d4aa90fdef9..5239856ee7f 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatAgentsView.vue @@ -58,7 +58,6 @@ const sortOptions = computed(() => [ ]); function handleCreateAgent() { - chatStore.currentEditingAgent = null; uiStore.openModalWithData({ name: AGENT_EDITOR_MODAL_KEY, data: { @@ -81,18 +80,13 @@ async function handleEditAgent(model: ChatHubConversationModel) { } if (model.provider === 'custom-agent') { - try { - await chatStore.fetchCustomAgent(model.agentId); - uiStore.openModalWithData({ - name: AGENT_EDITOR_MODAL_KEY, - data: { - agentId: model.agentId, - credentials: credentialsByProvider, - }, - }); - } catch (error) { - toast.showError(error, i18n.baseText('chatHub.agents.loadError')); - } + uiStore.openModalWithData({ + name: AGENT_EDITOR_MODAL_KEY, + data: { + agentId: model.agentId, + credentials: credentialsByProvider, + }, + }); } } 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 ba60819f1fd..487265665fe 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue @@ -423,8 +423,8 @@ function handleSwitchAlternative(messageId: string) { chatStore.switchAlternative(sessionId.value, messageId); } -function handleConfigureCredentials(_provider: ChatHubLLMProvider) { - // todo call model selector to open model +function handleConfigureCredentials(provider: ChatHubLLMProvider) { + headerRef.value?.openCredentialSelector(provider); } function handleConfigureModel() { @@ -444,25 +444,18 @@ async function handleUpdateTools(newTools: INode[]) { } } -async function handleEditAgent(agentId: string) { - try { - await chatStore.fetchCustomAgent(agentId); - - uiStore.openModalWithData({ - name: AGENT_EDITOR_MODAL_KEY, - data: { - agentId, - credentials: credentialsByProvider, - onCreateCustomAgent: handleSelectModel, - }, - }); - } catch (error) { - toast.showError(error, 'Failed to load agent'); - } +function handleEditAgent(agentId: string) { + uiStore.openModalWithData({ + name: AGENT_EDITOR_MODAL_KEY, + data: { + agentId, + credentials: credentialsByProvider, + onCreateCustomAgent: handleSelectModel, + }, + }); } function openNewAgentCreator() { - chatStore.currentEditingAgent = null; uiStore.openModalWithData({ name: AGENT_EDITOR_MODAL_KEY, data: { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts index 7481b9b0da3..94fdeb8e142 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts @@ -47,7 +47,12 @@ import type { ChatStreamingState, } from './chat.types'; import { retry } from '@n8n/utils/retry'; -import { buildUiMessages, isLlmProviderModel, isMatchedAgent } from './chat.utils'; +import { + buildUiMessages, + createSessionFromStreamingState, + isLlmProviderModel, + isMatchedAgent, +} from './chat.utils'; import { createAiMessageFromStreamingState, flattenModel } from './chat.utils'; import { useToast } from '@/app/composables/useToast'; import { useTelemetry } from '@/app/composables/useTelemetry'; @@ -64,7 +69,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => { const sessions = ref(); const sessionsLoadingMore = ref(false); - const currentEditingAgent = ref(null); const streaming = ref(); const settingsLoading = ref(false); const settings = ref | null>(null); @@ -353,21 +357,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { hasMore: false, nextCursor: null, ...sessions.value, - data: [ - ...(sessions.value?.data ?? []), - { - id: streaming.value.sessionId, - title: 'New Chat', - ownerId: '', - lastMessageAt: new Date().toISOString(), - credentialId: null, - agentName: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - tools: [], - ...flattenModel(streaming.value.model), - }, - ], + data: [...(sessions.value?.data ?? []), createSessionFromStreamingState(streaming.value)], }; } @@ -526,6 +516,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { sessionId, model, retryOfMessageId: null, + tools, }; sendMessageApi( @@ -596,6 +587,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { sessionId, model, retryOfMessageId: null, + tools: [], }; editMessageApi( @@ -632,6 +624,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { sessionId, model, retryOfMessageId: retryId, + tools: [], }; regenerateMessageApi( @@ -721,9 +714,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { } async function fetchCustomAgent(agentId: string): Promise { - const agent = await fetchAgentApi(rootStore.restApiContext, agentId); - currentEditingAgent.value = agent; - return agent; + return await fetchAgentApi(rootStore.restApiContext, agentId); } function getCustomAgent(agentId: string) { @@ -860,7 +851,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => { */ agents: computed(() => agents.value ?? emptyChatModelsResponse), agentsReady: computed(() => agents.value !== undefined), - currentEditingAgent, getAgent, fetchAgents, getCustomAgent, diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.types.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.types.ts index b3f29fb22db..3912f4547ec 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.types.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.types.ts @@ -9,6 +9,7 @@ import { type EnrichedStructuredChunk, type ChatHubProvider, } from '@n8n/api-types'; +import type { INode } from 'n8n-workflow'; import { z } from 'zod'; export interface UserMessage { @@ -78,6 +79,7 @@ export interface ChatStreamingState extends Partial(null); +const selectedModel = ref(null); const isSaving = ref(false); const isDeleting = ref(false); const tools = ref([]); const agentSelectedCredentials = ref({}); +const credentialIdForSelectedModelProvider = computed( + () => selectedModel.value && agentMergedCredentials.value[selectedModel.value.provider], +); +const selectedAgent = computed( + () => selectedModel.value && chatStore.getAgent(selectedModel.value), +); const isEditMode = computed(() => !!props.data.agentId); +const isLoadingAgent = computed(() => isEditMode.value && !customAgent.value); const title = computed(() => isEditMode.value ? i18n.baseText('chatHub.agent.editor.title.edit') @@ -57,7 +66,8 @@ const isValid = computed(() => { return ( name.value.trim().length > 0 && systemPrompt.value.trim().length > 0 && - selectedModel.value !== null + selectedModel.value !== null && + !!credentialIdForSelectedModelProvider.value ); }); @@ -68,37 +78,34 @@ const agentMergedCredentials = computed((): CredentialsMap => { }; }); -function loadAgent() { - const customAgent = chatStore.currentEditingAgent; +watch( + customAgent, + (agent) => { + if (!agent) return; - if (!customAgent) return; + name.value = agent.name; + description.value = agent.description ?? ''; + systemPrompt.value = agent.systemPrompt; + selectedModel.value = { provider: agent.provider, model: agent.model }; + tools.value = agent.tools || []; - name.value = customAgent.name; - description.value = customAgent.description ?? ''; - systemPrompt.value = customAgent.systemPrompt; - selectedModel.value = chatStore.getAgent(customAgent); - tools.value = customAgent.tools || []; + if (agent.credentialId) { + agentSelectedCredentials.value[agent.provider] = agent.credentialId; + } + }, + { immediate: true }, +); - if (customAgent.credentialId) { - agentSelectedCredentials.value[customAgent.provider] = customAgent.credentialId; - } -} - -onMounted(() => { - if (props.data.agentId) { - loadAgent(); - } -}); - -function onCredentialSelected(provider: ChatHubProvider, credentialId: string) { +function onCredentialSelected(provider: ChatHubProvider, credentialId: string | null) { agentSelectedCredentials.value = { ...agentSelectedCredentials.value, [provider]: credentialId, }; } -function onModelChange(model: ChatModelDto) { - selectedModel.value = model; +function onModelChange(agent: ChatModelDto) { + assert(isLlmProviderModel(agent.model)); + selectedModel.value = agent.model; } async function onSave() { @@ -107,21 +114,14 @@ async function onSave() { isSaving.value = true; try { assert(selectedModel.value); - - const model = 'model' in selectedModel.value ? selectedModel.value.model : undefined; - - assert(isLlmProviderModel(model)); - - const credentialId = agentMergedCredentials.value[model.provider]; - - assert(credentialId); + assert(credentialIdForSelectedModelProvider.value); const payload = { name: name.value.trim(), description: description.value.trim() || undefined, systemPrompt: systemPrompt.value.trim(), - ...model, - credentialId, + ...selectedModel.value, + credentialId: credentialIdForSelectedModelProvider.value, tools: tools.value, }; @@ -197,7 +197,17 @@ function onSelectTools(newTools: INode[]) { min-height="400px" > 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 6ccab7f8d16..45b3416b34c 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 @@ -4,7 +4,12 @@ import ChatSidebarOpener from '@/features/ai/chatHub/components/ChatSidebarOpene 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 type { ChatHubProvider, ChatModelDto, ChatSessionId } from '@n8n/api-types'; +import type { + ChatHubLLMProvider, + ChatHubProvider, + ChatModelDto, + ChatSessionId, +} from '@n8n/api-types'; import { N8nButton, N8nIconButton } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import { useTemplateRef } from 'vue'; @@ -21,7 +26,7 @@ const emit = defineEmits<{ renameConversation: [id: ChatSessionId, title: string]; editCustomAgent: [agentId: string]; createCustomAgent: []; - selectCredential: [provider: ChatHubProvider, credentialId: string]; + selectCredential: [provider: ChatHubProvider, credentialId: string | null]; openWorkflow: [workflowId: string]; }>(); @@ -42,6 +47,8 @@ function onNewChat() { defineExpose({ openModelSelector: () => modelSelectorRef.value?.open(), + openCredentialSelector: (provider: ChatHubLLMProvider) => + modelSelectorRef.value?.openCredentialSelector(provider), }); 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 2ff28cf1cfb..05a7b6678cb 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 @@ -366,7 +366,7 @@ defineExpose({ transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); &:focus-within, - &:hover { + &:hover:has(textarea:not(:disabled)) { border-color: var(--color--secondary); } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue index 395bb79a9cd..45c1838927b 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebarContent.vue @@ -17,6 +17,7 @@ import { useIntersectionObserver } from '@vueuse/core'; import ChatSessionMenuItem from './ChatSessionMenuItem.vue'; import SkeletonMenuItem from './SkeletonMenuItem.vue'; import { useTelemetry } from '@/app/composables/useTelemetry'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useI18n } from '@n8n/i18n'; defineProps<{ isMobileDevice: boolean }>(); @@ -28,7 +29,11 @@ const toast = useToast(); const message = useMessage(); const sidebar = useChatHubSidebarState(); const settingsStore = useSettingsStore(); +const credentialsStore = useCredentialsStore(); const telemetry = useTelemetry(); +const readyToShowSessions = computed( + () => chatStore.sessionsReady && credentialsStore.allCredentialTypes.length > 0, +); const i18n = useI18n(); const renamingSessionId = ref(); @@ -148,13 +153,11 @@ onMounted(() => {
-
+
import { ref, computed } from 'vue'; -import { N8nButton, N8nHeading, N8nOption, N8nSelect, N8nText } from '@n8n/design-system'; +import { N8nButton, N8nHeading, N8nIconButton, N8nText } from '@n8n/design-system'; import Modal from '@/app/components/Modal.vue'; -import { useCredentialsStore } from '@/features/credentials/credentials.store'; -import type { ICredentialsResponse } from '@/features/credentials/credentials.types'; import { createEventBus } from '@n8n/utils/event-bus'; import { type ChatHubLLMProvider, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types'; import { providerDisplayNames } from '@/features/ai/chatHub/constants'; import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue'; +import CredentialPicker from '@/features/credentials/components/CredentialPicker/CredentialPicker.vue'; import { useI18n } from '@n8n/i18n'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; +import { useMessage } from '@/app/composables/useMessage'; +import { useToast } from '@/app/composables/useToast'; +import { MODAL_CONFIRM } from '@/app/constants'; const props = defineProps<{ modalName: string; data: { provider: ChatHubLLMProvider; initialValue: string | null; - onSelect: (provider: ChatHubLLMProvider, credentialId: string) => void; + onSelect: (provider: ChatHubLLMProvider, credentialId: string | null) => void; onCreateNew: (provider: ChatHubLLMProvider) => void; }; }>(); const i18n = useI18n(); +const message = useMessage(); +const toast = useToast(); const credentialsStore = useCredentialsStore(); const modalBus = ref(createEventBus()); const selectedCredentialId = ref(props.data.initialValue); -const availableCredentials = computed(() => { - return credentialsStore.getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[props.data.provider]); +const credentialType = computed(() => PROVIDER_CREDENTIAL_TYPE_MAP[props.data.provider]); + +const selectedCredential = computed(() => { + if (!selectedCredentialId.value) { + return null; + } + return credentialsStore.getCredentialById(selectedCredentialId.value); }); function onCredentialSelect(credentialId: string) { selectedCredentialId.value = credentialId; } +function onCredentialDeselect() { + selectedCredentialId.value = null; +} + +async function onDeleteCredential() { + if (!selectedCredential.value) { + return; + } + + const credentialIdToDelete = selectedCredential.value.id; + + const deleteConfirmed = await message.confirm( + i18n.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.message', { + interpolate: { savedCredentialName: selectedCredential.value.name }, + }), + i18n.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.headline'), + { + confirmButtonText: i18n.baseText( + 'credentialEdit.credentialEdit.confirmMessage.deleteCredential.confirmButtonText', + ), + }, + ); + + if (deleteConfirmed !== MODAL_CONFIRM) { + return; + } + + try { + await credentialsStore.deleteCredential({ id: credentialIdToDelete }); + selectedCredentialId.value = null; + + if (credentialIdToDelete === props.data.initialValue) { + props.data.onSelect(props.data.provider, null); + } + + modalBus.value.emit('close'); + } catch (error) { + toast.showError( + error, + i18n.baseText('credentialEdit.credentialEdit.showError.deleteCredential.title'), + ); + } +} + function onConfirm() { if (selectedCredentialId.value) { props.data.onSelect(props.data.provider, selectedCredentialId.value); @@ -40,11 +94,6 @@ function onConfirm() { } } -function onCreateNew() { - props.data.onCreateNew(props.data.provider); - modalBus.value.emit('close'); -} - function onCancel() { modalBus.value.emit('close'); } @@ -88,34 +137,36 @@ function onCancel() { }) }} - - + - + +
@@ -131,14 +182,10 @@ function onCancel() { .footer { display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; - width: 100%; -} - -.footerRight { - display: flex; gap: var(--spacing--2xs); + width: 100%; } .header { @@ -151,4 +198,15 @@ function onCancel() { flex-shrink: 0; flex-grow: 0; } + +.credentialContainer { + display: flex; + align-items: center; + gap: var(--spacing--2xs); + width: 100%; +} + +.credentialPicker { + width: 100%; +} 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 04e5e5b5ab2..3c45edaa7b4 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 @@ -55,10 +55,10 @@ const { const emit = defineEmits<{ change: [ChatModelDto]; createCustomAgent: []; - selectCredential: [provider: ChatHubProvider, credentialId: string]; + selectCredential: [provider: ChatHubProvider, credentialId: string | null]; }>(); -function handleSelectCredentials(provider: ChatHubProvider, id: string) { +function handleSelectCredentials(provider: ChatHubProvider, id: string | null) { emit('selectCredential', provider, id); } @@ -90,6 +90,12 @@ const credentialsName = computed(() => : undefined, ); const isCredentialsRequired = computed(() => isLlmProviderModel(selectedAgent?.model)); +const isCredentialsMissing = computed( + () => + isCredentialsRequired.value && + selectedAgent?.model.provider && + !credentials?.[selectedAgent?.model.provider], +); const menu = computed(() => { const menuItems: (typeof N8nNavigationDropdown)['menu'] = []; @@ -306,11 +312,13 @@ watch( defineExpose({ open: () => dropdownRef.value?.open(), + openCredentialSelector: (provider: ChatHubLLMProvider) => + openCredentialsSelectorOrCreate(provider), });