fix(editor): Chat UI feedback 3 (no-changelog) (#22274)

This commit is contained in:
Suguru Inoue
2025-11-27 08:33:01 +01:00
committed by GitHub
parent 68309046c0
commit d485fc9f6f
15 changed files with 303 additions and 166 deletions
@@ -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",
@@ -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,
},
});
}
}
@@ -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: {
@@ -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<ChatHubConversationsResponse>();
const sessionsLoadingMore = ref(false);
const currentEditingAgent = ref<ChatHubAgentDto | null>(null);
const streaming = ref<ChatStreamingState>();
const settingsLoading = ref(false);
const settings = ref<Record<ChatHubLLMProvider, ChatProviderSettingsDto> | 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<ChatHubAgentDto> {
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,
@@ -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<EnrichedStructuredChunk['met
sessionId: ChatSessionId;
model: ChatHubConversationModel;
retryOfMessageId: ChatMessageId | null;
tools: INode[];
}
export interface FlattenedModel {
@@ -332,3 +332,18 @@ export function isLlmProviderModel(
): model is ChatHubConversationModel & { provider: ChatHubLLMProvider } {
return isLlmProvider(model?.provider);
}
export function createSessionFromStreamingState(streaming: ChatStreamingState): ChatHubSessionDto {
return {
id: streaming.sessionId,
title: 'New Chat',
ownerId: '',
lastMessageAt: new Date().toISOString(),
credentialId: null,
agentName: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tools: streaming.tools,
...flattenModel(streaming.model),
};
}
@@ -4,16 +4,17 @@ import { useMessage } from '@/app/composables/useMessage';
import { useToast } from '@/app/composables/useToast';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
import type { ChatHubProvider, ChatModelDto } from '@n8n/api-types';
import { N8nButton, N8nHeading, N8nInput, N8nInputLabel } from '@n8n/design-system';
import type { ChatHubBaseLLMModel, ChatHubProvider, ChatModelDto } from '@n8n/api-types';
import { N8nButton, N8nHeading, N8nInput, N8nInputLabel, N8nSpinner } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { assert } from '@n8n/utils/assert';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed, onMounted, ref } from 'vue';
import { computed, ref, watch } 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';
import { useCustomAgent } from '@/features/ai/chatHub/composables/useCustomAgent';
const props = defineProps<{
modalName: string;
@@ -30,18 +31,26 @@ const i18n = useI18n();
const toast = useToast();
const message = useMessage();
const modalBus = ref(createEventBus());
const customAgent = useCustomAgent(props.data.agentId);
const name = ref('');
const description = ref('');
const systemPrompt = ref('');
const selectedModel = ref<ChatModelDto | null>(null);
const selectedModel = ref<ChatHubBaseLLMModel | null>(null);
const isSaving = ref(false);
const isDeleting = ref(false);
const tools = ref<INode[]>([]);
const agentSelectedCredentials = ref<CredentialsMap>({});
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"
>
<template #header>
<N8nHeading tag="h2" size="large">{{ title }}</N8nHeading>
<div :class="$style.header">
<N8nHeading tag="h2" size="large">{{ title }}</N8nHeading>
<N8nButton
v-if="isEditMode"
type="secondary"
icon="trash-2"
:disabled="isDeleting"
:loading="isDeleting"
@click="onDelete"
/>
</div>
</template>
<template #content>
<div :class="$style.content">
@@ -212,6 +222,7 @@ function onSelectTools(newTools: INode[]) {
:placeholder="i18n.baseText('chatHub.agent.editor.name.placeholder')"
:maxlength="128"
:class="$style.input"
:disabled="isLoadingAgent"
/>
</N8nInputLabel>
@@ -227,6 +238,7 @@ function onSelectTools(newTools: INode[]) {
:maxlength="512"
:rows="3"
:class="$style.input"
:disabled="isLoadingAgent"
/>
</N8nInputLabel>
@@ -242,6 +254,7 @@ function onSelectTools(newTools: INode[]) {
:placeholder="i18n.baseText('chatHub.agent.editor.systemPrompt.placeholder')"
:rows="6"
:class="$style.input"
:disabled="isLoadingAgent"
/>
</N8nInputLabel>
@@ -253,9 +266,10 @@ function onSelectTools(newTools: INode[]) {
:required="true"
>
<ModelSelector
:selected-agent="selectedModel"
:selected-agent="selectedAgent"
:include-custom-agents="false"
:credentials="agentMergedCredentials"
:disabled="isLoadingAgent"
@change="onModelChange"
@select-credential="onCredentialSelected"
/>
@@ -268,33 +282,42 @@ function onSelectTools(newTools: INode[]) {
:required="false"
>
<div>
<ToolsSelector :disabled="false" :selected="tools" @select="onSelectTools" />
<ToolsSelector :disabled="isLoadingAgent" :selected="tools" @select="onSelectTools" />
</div>
</N8nInputLabel>
</div>
<N8nSpinner v-if="isLoadingAgent" :class="$style.spinner" size="xlarge" />
</div>
</template>
<template #footer>
<div :class="$style.footer">
<div :class="$style.footerRight">
<N8nButton
v-if="isEditMode"
type="secondary"
icon="trash-2"
:disabled="isDeleting"
:loading="isDeleting"
@click="onDelete"
/>
<N8nButton type="primary" :disabled="!isValid || isSaving" @click="onSave">
{{ saveButtonLabel }}
</N8nButton>
</div>
<N8nButton type="secondary" @click="modalBus.emit('close')">{{
i18n.baseText('chatHub.tools.editor.cancel')
}}</N8nButton>
<N8nButton type="primary" :disabled="!isValid || isSaving" @click="onSave">
{{ saveButtonLabel }}
</N8nButton>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.spinner {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--s);
padding-right: var(--spacing--xl);
}
.content {
display: flex;
flex-direction: column;
@@ -315,11 +338,6 @@ function onSelectTools(newTools: INode[]) {
display: flex;
justify-content: flex-end;
align-items: center;
width: 100%;
}
.footerRight {
display: flex;
gap: var(--spacing--2xs);
}
</style>
@@ -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),
});
</script>
@@ -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);
}
@@ -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<string>();
@@ -148,13 +153,11 @@ onMounted(() => {
</div>
<N8nScrollArea as-child type="scroll">
<div :class="$style.items">
<div
v-if="groupedConversations.length === 0 && !chatStore.sessionsReady"
:class="$style.group"
>
<div v-if="!readyToShowSessions" :class="$style.group">
<SkeletonMenuItem v-for="i in 10" :key="`loading-${i}`" />
</div>
<div
v-else
v-for="(group, index) in groupedConversations"
:key="group.group"
:class="$style.group"
@@ -1,38 +1,92 @@
<script setup lang="ts">
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<string | null>(props.data.initialValue);
const availableCredentials = computed<ICredentialsResponse[]>(() => {
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() {
})
}}
</N8nText>
<N8nSelect
:model-value="selectedCredentialId"
size="large"
:placeholder="i18n.baseText('chatHub.credentials.selector.placeholder')"
@update:model-value="onCredentialSelect"
>
<N8nOption
v-for="credential in availableCredentials"
:key="credential.id"
:value="credential.id"
:label="credential.name"
<div :class="$style.credentialContainer">
<CredentialPicker
:class="$style.credentialPicker"
:app-name="providerDisplayNames[data.provider]"
:credential-type="credentialType"
:selected-credential-id="selectedCredentialId"
:hide-create-new="true"
@credential-selected="onCredentialSelect"
@credential-deselected="onCredentialDeselect"
/>
</N8nSelect>
<N8nIconButton
v-if="selectedCredentialId"
native-type="button"
:title="i18n.baseText('chatHub.credentials.selector.deleteButton')"
icon="trash-2"
icon-size="large"
type="secondary"
@click="onDeleteCredential"
/>
</div>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton type="secondary" @click="onCreateNew">
{{ i18n.baseText('chatHub.credentials.selector.createNew') }}
<N8nButton type="tertiary" @click="onCancel">
{{ i18n.baseText('chatHub.credentials.selector.cancel') }}
</N8nButton>
<N8nButton type="primary" :disabled="!selectedCredentialId" @click="onConfirm">
{{ i18n.baseText('chatHub.credentials.selector.confirm') }}
</N8nButton>
<div :class="$style.footerRight">
<N8nButton type="tertiary" @click="onCancel">
{{ i18n.baseText('chatHub.credentials.selector.cancel') }}
</N8nButton>
<N8nButton type="primary" :disabled="!selectedCredentialId" @click="onConfirm">
{{ i18n.baseText('chatHub.credentials.selector.confirm') }}
</N8nButton>
</div>
</div>
</template>
</Modal>
@@ -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%;
}
</style>
@@ -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),
});
</script>
<template>
<N8nNavigationDropdown ref="dropdownRef" :menu="menu" @select="onSelect">
<N8nNavigationDropdown ref="dropdownRef" :menu="menu" teleport @select="onSelect">
<template #item-icon="{ item }">
<CredentialIcon
v-if="item.id in PROVIDER_CREDENTIAL_TYPE_MAP"
@@ -340,6 +348,14 @@ defineExpose({
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
{{ credentialsName }}
</N8nText>
<N8nText v-else-if="isCredentialsMissing" size="xsmall" color="danger">
<N8nIcon
icon="node-validation-error"
size="xsmall"
:class="$style.credentialsMissingIcon"
/>
{{ i18n.baseText('chatHub.agent.credentialsMissing') }}
</N8nText>
</div>
<N8nIcon icon="chevron-down" size="medium" />
</N8nButton>
@@ -356,6 +372,11 @@ defineExpose({
text-decoration: none !important;
}
.credentialsMissingIcon {
display: inline-block;
margin-bottom: -1px;
}
.selected {
display: flex;
flex-direction: column;
@@ -85,7 +85,7 @@ const onClick = () => {
gap: var(--spacing--2xs);
&.transparentBg {
background-color: transparent;
background-color: transparent !important;
}
}
@@ -81,14 +81,20 @@ export function useChatCredentials(userId: string) {
const credentialsByProvider = computed<CredentialsMap | null>(() =>
isCredentialsReady.value
? {
...autoSelectCredentials.value,
...selectedCredentials.value,
}
? chatHubProviderSchema.options.reduce<CredentialsMap>((acc, provider) => {
const cred = selectedCredentials.value[provider] ?? null;
acc[provider] =
cred && credentialsStore.allCredentials.some((c) => c.id === cred)
? cred
: autoSelectCredentials.value[provider];
return acc;
}, {})
: null,
);
function selectCredential(provider: ChatHubProvider, id: string) {
function selectCredential(provider: ChatHubProvider, id: string | null) {
selectedCredentials.value = { ...selectedCredentials.value, [provider]: id };
}
@@ -0,0 +1,29 @@
import type { ChatHubAgentDto } from '@n8n/api-types';
import { ref, toValue, watch, type MaybeRef } from 'vue';
import { useChatStore } from '../chat.store';
import { useToast } from '@/app/composables/useToast';
export function useCustomAgent(agentId?: MaybeRef<string | undefined>) {
const store = useChatStore();
const agent = ref<ChatHubAgentDto>();
const toast = useToast();
watch(
() => toValue(agentId),
async (id) => {
if (!id) {
agent.value = undefined;
return;
}
try {
agent.value = await store.fetchCustomAgent(id);
} catch (error) {
toast.showError(error, 'Failed to load agent');
}
},
{ immediate: true },
);
return agent;
}