feat(editor): Improve custom agent support in chat (no-changelog) (#21393)

This commit is contained in:
Suguru Inoue 2025-10-31 12:59:16 +01:00 committed by GitHub
parent 956dd09fcc
commit d5c275df03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 194 additions and 126 deletions

View file

@ -2,7 +2,7 @@
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import {
N8nButton,
N8nIcon,
@ -18,12 +18,13 @@ import AgentEditorModal from '@/features/ai/chatHub/components/AgentEditorModal.
import ChatAgentCard from '@/features/ai/chatHub/components/ChatAgentCard.vue';
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
import { useUsersStore } from '@/features/settings/users/users.store';
import type { ChatHubConversationModel } from '@n8n/api-types';
import { type ChatHubConversationModel } from '@n8n/api-types';
import { filterAndSortAgents, stringifyModel } from '@/features/ai/chatHub/chat.utils';
import type { ChatAgentFilter } from '@/features/ai/chatHub/chat.types';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import { useMediaQuery } from '@vueuse/core';
import { MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants';
import { useRouter } from 'vue-router';
const chatStore = useChatStore();
const uiStore = useUIStore();
@ -31,6 +32,7 @@ const toast = useToast();
const message = useMessage();
const usersStore = useUsersStore();
const sidebar = useChatHubSidebarState();
const router = useRouter();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const editingAgentId = ref<string | undefined>(undefined);
@ -68,16 +70,26 @@ function handleCreateAgent() {
}
async function handleEditAgent(model: ChatHubConversationModel) {
if (model.provider !== 'custom-agent') {
if (model.provider === 'n8n') {
const routeData = router.resolve({
name: VIEWS.WORKFLOW,
params: {
name: model.workflowId,
},
});
window.open(routeData.href, '_blank');
return;
}
try {
await chatStore.fetchCustomAgent(model.agentId);
editingAgentId.value = model.agentId;
uiStore.openModal('agentEditor');
} catch (error) {
toast.showError(error, 'Failed to load agent');
if (model.provider === 'custom-agent') {
try {
await chatStore.fetchCustomAgent(model.agentId);
editingAgentId.value = model.agentId;
uiStore.openModal('agentEditor');
} catch (error) {
toast.showError(error, 'Failed to load agent');
}
}
}

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast';
import { LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL } from '@/constants';
import { LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL, VIEWS } from '@/constants';
import { findOneFromModelsResponse, 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';
@ -245,6 +245,17 @@ watch(
{ immediate: true },
);
// Reload models when credentials are updated
watch(
credentialsByProvider,
(credentials) => {
if (credentials) {
void chatStore.fetchAgents(credentials);
}
},
{ immediate: true },
);
function onSubmit(message: string) {
if (
!message.trim() ||
@ -369,6 +380,12 @@ function openNewAgentCreator() {
function closeAgentEditor() {
editingAgentId.value = undefined;
}
function handleOpenWorkflow(workflowId: string) {
const routeData = router.resolve({ name: VIEWS.WORKFLOW, params: { name: workflowId } });
window.open(routeData.href, '_blank');
}
</script>
<template>
@ -385,10 +402,12 @@ function closeAgentEditor() {
ref="headerRef"
:selected-model="selectedModel ?? null"
:credentials="credentialsByProvider"
:ready-to-show-model-selector="chatStore.agentsReady"
@select-model="handleSelectModel"
@edit-custom-agent="handleEditAgent"
@create-custom-agent="openNewAgentCreator"
@select-credential="selectCredential"
@open-workflow="handleOpenWorkflow"
/>
<AgentEditorModal

View file

@ -43,6 +43,7 @@ import type {
ChatStreamingState,
} from './chat.types';
import { retry } from '@n8n/utils/retry';
import { isMatchedAgent } from './chat.utils';
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
export const useChatStore = defineStore(CHAT_STORE, () => {
@ -675,15 +676,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
function getAgent(model: ChatHubConversationModel) {
if (!agents.value) return;
return agents.value[model.provider].models.find((m) => {
if (model.provider === 'n8n') {
return m.model.provider === 'n8n' && m.model.workflowId === model.workflowId;
} else if (model.provider === 'custom-agent') {
return m.model.provider === 'custom-agent' && m.model.agentId === model.agentId;
} else {
return m.model.provider === model.provider && m.model.model === model.model;
}
});
return agents.value[model.provider].models.find((agent) => isMatchedAgent(agent, model));
}
return {

View file

@ -217,6 +217,18 @@ export function fromStringToModel(value: string): ChatHubConversationModel | und
: { provider: parsedProvider, model: identifier };
}
export function isMatchedAgent(agent: ChatModelDto, model: ChatHubConversationModel): boolean {
if (model.provider === 'n8n') {
return agent.model.provider === 'n8n' && agent.model.workflowId === model.workflowId;
}
if (model.provider === 'custom-agent') {
return agent.model.provider === 'custom-agent' && agent.model.agentId === model.agentId;
}
return agent.model.provider === model.provider && agent.model.model === model.model;
}
export function createAiMessageFromStreamingState(
sessionId: ChatSessionId,
messageId: ChatMessageId,

View file

@ -91,7 +91,7 @@ function resetForm() {
// Watch for modal opening
watch(
() => uiStore.isModalActiveById.agentEditor,
() => uiStore.modalsById.agentEditor?.open,
(isOpen) => {
if (isOpen) {
if (props.agentId) {

View file

@ -19,15 +19,10 @@ defineProps<{
:size="size === 'lg' ? 'xxlarge' : size === 'sm' ? 'large' : 'xlarge'"
/>
<N8nAvatar
v-else-if="agent.model.provider === 'custom-agent'"
v-else-if="agent.model.provider === 'custom-agent' || agent.model.provider === 'n8n'"
:first-name="agent.name"
:size="size === 'lg' ? 'medium' : size === 'sm' ? 'xxsmall' : 'xsmall'"
/>
<N8nIcon
v-else-if="agent.model.provider === 'n8n'"
icon="robot"
:size="size === 'lg' ? 'xxlarge' : size === 'sm' ? 'large' : 'xlarge'"
/>
<CredentialIcon
v-else
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[agent.model.provider]"

View file

@ -40,7 +40,7 @@ const emit = defineEmits<{
</div>
</div>
<div v-if="agent.model.provider === 'custom-agent'" :class="$style.actions">
<div :class="$style.actions">
<N8nIconButton
icon="pen"
type="tertiary"
@ -49,6 +49,7 @@ const emit = defineEmits<{
@click.prevent="emit('edit')"
/>
<N8nIconButton
v-if="agent.model.provider === 'custom-agent'"
icon="trash-2"
type="tertiary"
size="medium"

View file

@ -8,9 +8,10 @@ import { N8nButton, N8nIconButton } from '@n8n/design-system';
import { useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
const { selectedModel, credentials } = defineProps<{
const { selectedModel, credentials, readyToShowModelSelector } = defineProps<{
selectedModel: ChatModelDto | null;
credentials: CredentialsMap | null;
readyToShowModelSelector: boolean;
}>();
const emit = defineEmits<{
@ -19,6 +20,7 @@ const emit = defineEmits<{
editCustomAgent: [agentId: string];
createCustomAgent: [];
selectCredential: [provider: ChatHubProvider, credentialId: string];
openWorkflow: [workflowId: string];
}>();
const sidebar = useChatHubSidebarState();
@ -62,6 +64,7 @@ defineExpose({
@click="onNewChat"
/>
<ModelSelector
v-if="readyToShowModelSelector"
ref="modelSelectorRef"
:selectedAgent="selectedModel"
:credentials="credentials"
@ -77,10 +80,19 @@ defineExpose({
:class="$style.editAgent"
type="secondary"
size="small"
icon="cog"
icon="settings"
label="Edit Agent"
@click="emit('editCustomAgent', selectedModel.model.agentId)"
/>
<N8nButton
v-if="selectedModel?.model.provider === 'n8n'"
:class="$style.editAgent"
type="secondary"
size="small"
icon="settings"
label="Open Workflow"
@click="emit('openWorkflow', selectedModel.model.workflowId)"
/>
</div>
</template>

View file

@ -178,12 +178,11 @@ onBeforeMount(() => {
<ChatTypingIndicator v-if="isStreaming" :class="$style.typingIndicator" />
<ChatMessageActions
v-else
:type="message.type"
:just-copied="justCopied"
:is-speech-synthesis-available="speech.isSupported.value"
:is-speaking="speech.isPlaying.value"
:class="$style.actions"
:message-id="message.id"
:message="message"
:alternatives="message.alternatives"
@copy="handleCopy"
@edit="handleEdit"

View file

@ -1,20 +1,22 @@
<script setup lang="ts">
import type { ChatHubMessageType, ChatMessageId } from '@n8n/api-types';
import { N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
import { VIEWS } from '@/constants';
import type { ChatMessage } from '@/features/ai/chatHub/chat.types';
import type { ChatMessageId } from '@n8n/api-types';
import { N8nIconButton, N8nLink, N8nText, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const i18n = useI18n();
const router = useRouter();
const { type, justCopied, messageId, alternatives, isSpeaking, isSpeechSynthesisAvailable } =
defineProps<{
type: ChatHubMessageType;
justCopied: boolean;
messageId: ChatMessageId;
alternatives: ChatMessageId[];
isSpeechSynthesisAvailable: boolean;
isSpeaking: boolean;
}>();
const { justCopied, message, alternatives, isSpeaking, isSpeechSynthesisAvailable } = defineProps<{
justCopied: boolean;
message: ChatMessage;
alternatives: ChatMessageId[];
isSpeechSynthesisAvailable: boolean;
isSpeaking: boolean;
}>();
const emit = defineEmits<{
copy: [];
@ -29,7 +31,17 @@ const copyTooltip = computed(() => {
});
const currentAlternativeIndex = computed(() => {
return alternatives.findIndex((id) => id === messageId);
return alternatives.findIndex((id) => id === message.id);
});
const executionUrl = computed(() => {
if (message.type === 'ai' && message.provider === 'n8n' && message.executionId) {
return router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: message.workflowId, executionId: message.executionId },
}).href;
}
return undefined;
});
function handleCopy() {
@ -62,7 +74,7 @@ function handleReadAloud() {
<template #content>{{ copyTooltip }}</template>
</N8nTooltip>
<N8nTooltip
v-if="isSpeechSynthesisAvailable && type === 'ai'"
v-if="isSpeechSynthesisAvailable && message.type === 'ai'"
placement="bottom"
:show-after="300"
>
@ -79,7 +91,7 @@ function handleReadAloud() {
<N8nIconButton icon="pen" type="tertiary" size="medium" text @click="handleEdit" />
<template #content>Edit</template>
</N8nTooltip>
<N8nTooltip v-if="type === 'ai'" placement="bottom" :show-after="300">
<N8nTooltip v-if="message.type === 'ai'" placement="bottom" :show-after="300">
<N8nIconButton
icon="refresh-cw"
type="tertiary"
@ -89,6 +101,15 @@ function handleReadAloud() {
/>
<template #content>Regenerate</template>
</N8nTooltip>
<N8nTooltip v-if="executionUrl && message.executionId" placement="bottom" :show-after="300">
<N8nIconButton icon="info" type="tertiary" size="medium" text />
<template #content>
Execution ID:
<N8nLink :to="executionUrl" :new-window="true">
{{ message.executionId }}
</N8nLink>
</template>
</N8nTooltip>
<template v-if="alternatives.length > 1">
<N8nIconButton
icon="chevron-left"

View file

@ -2,8 +2,17 @@
import { computed, ref, useTemplateRef, watch } from 'vue';
import { N8nNavigationDropdown, N8nIcon, N8nButton, N8nText, N8nAvatar } from '@n8n/design-system';
import { type ComponentProps } from 'vue-component-type-helpers';
import { PROVIDER_CREDENTIAL_TYPE_MAP, chatHubProviderSchema } from '@n8n/api-types';
import type { ChatHubProvider, ChatHubLLMProvider, ChatModelDto } from '@n8n/api-types';
import {
PROVIDER_CREDENTIAL_TYPE_MAP,
chatHubLLMProviderSchema,
emptyChatModelsResponse,
} from '@n8n/api-types';
import type {
ChatHubProvider,
ChatHubLLMProvider,
ChatModelDto,
ChatModelsResponse,
} from '@n8n/api-types';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { onClickOutside } from '@vueuse/core';
@ -13,9 +22,14 @@ import type { CredentialsMap } from '../chat.types';
import CredentialSelectorModal from './CredentialSelectorModal.vue';
import { useUIStore } from '@/stores/ui.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import { fromStringToModel, stringifyModel } from '@/features/ai/chatHub/chat.utils';
import {
fromStringToModel,
isMatchedAgent,
stringifyModel,
} from '@/features/ai/chatHub/chat.utils';
import { fetchChatModelsApi } from '@/features/ai/chatHub/chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
const NEW_AGENT_MENU_ID = 'agent::new';
@ -40,7 +54,7 @@ function handleSelectCredentials(provider: ChatHubProvider, id: string) {
}
const i18n = useI18n();
const chatStore = useChatStore();
const agents = ref<ChatModelsResponse>(emptyChatModelsResponse);
const dropdownRef = useTemplateRef('dropdownRef');
const credentialSelectorProvider = ref<ChatHubLLMProvider | null>(null);
const uiStore = useUIStore();
@ -53,78 +67,71 @@ const credentialsName = computed(() =>
);
const menu = computed(() => {
const customAgents = chatStore.agents['custom-agent'].models;
const customAgentOptions = customAgents.map<
ComponentProps<typeof N8nNavigationDropdown>['menu'][number]
>((agent) => ({
id: stringifyModel(agent.model),
title: agent.name,
disabled: false,
}));
const menuItems: (typeof N8nNavigationDropdown)['menu'] = [];
const customAgentMenu: ComponentProps<typeof N8nNavigationDropdown>['menu'][number] = {
id: 'custom-agents',
title: i18n.baseText('chatHub.agent.customAgents'),
icon: 'robot',
iconSize: 'large',
iconMargin: false,
submenu: [
...customAgentOptions,
...(customAgentOptions.length > 0 ? [{ isDivider: true as const, id: 'divider' }] : []),
if (includeCustomAgents) {
const customAgents = [
...agents.value['custom-agent'].models,
...agents.value['n8n'].models,
].map((agent) => ({
id: stringifyModel(agent.model),
title: agent.name,
disabled: false,
}));
menuItems.push({
id: 'custom-agents',
title: i18n.baseText('chatHub.agent.customAgents'),
icon: 'robot',
iconSize: 'large',
iconMargin: false,
submenu: [
...customAgents,
...(customAgents.length > 0 ? [{ isDivider: true as const, id: 'divider' }] : []),
{
id: NEW_AGENT_MENU_ID,
icon: 'plus',
title: i18n.baseText('chatHub.agent.newAgent'),
disabled: false,
},
],
});
}
for (const provider of chatHubLLMProviderSchema.options) {
const theAgents = agents.value[provider].models;
const error = agents.value[provider].error;
const agentOptions =
theAgents.length > 0
? theAgents
.filter((agent) => agent.model.provider !== 'custom-agent')
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((agent) => ({
id: stringifyModel(agent.model),
title: agent.name,
disabled: false,
}))
: error
? [{ id: `${provider}::error`, value: null, disabled: true, title: error }]
: [];
const submenu = agentOptions.concat([
...(agentOptions.length > 0 ? [{ isDivider: true as const, id: 'divider' }] : []),
{
id: NEW_AGENT_MENU_ID,
icon: 'plus',
title: i18n.baseText('chatHub.agent.newAgent'),
id: `${provider}::configure`,
icon: 'settings',
title: 'Configure credentials...',
disabled: false,
},
],
};
]);
const providerMenus = chatHubProviderSchema.options
.filter(
(provider) =>
provider !== 'custom-agent' && (!includeCustomAgents ? provider !== 'n8n' : true),
) // hide n8n agent for now
.map((provider) => {
const agents = chatStore.agents[provider].models;
const error = chatStore.agents[provider].error;
const agentOptions =
agents.length > 0
? agents
.filter((agent) => agent.model.provider !== 'custom-agent')
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((agent) => ({
id: stringifyModel(agent.model),
title: agent.name,
disabled: false,
}))
: error
? [{ id: `${provider}::error`, value: null, disabled: true, title: error }]
: [];
const submenu = agentOptions.concat([
...(provider !== 'n8n' && agentOptions.length > 0
? [{ isDivider: true as const, id: 'divider' }]
: []),
]);
if (provider !== 'n8n') {
submenu.push({
id: `${provider}::configure`,
icon: 'settings',
title: 'Configure credentials...',
disabled: false,
});
}
return {
id: provider,
title: providerDisplayNames[provider],
submenu,
};
menuItems.push({
id: provider,
title: providerDisplayNames[provider],
submenu,
});
}
return includeCustomAgents ? [customAgentMenu, ...providerMenus] : providerMenus;
return menuItems;
});
const selectedLabel = computed(() => selectedAgent?.name ?? 'Select model');
@ -164,7 +171,9 @@ function onSelect(id: string) {
return;
}
const selected = chatStore.getAgent(parsedModel);
const selected = agents.value[parsedModel.provider].models.find((a) =>
isMatchedAgent(a, parsedModel),
);
if (!selected) {
return;
@ -182,12 +191,12 @@ onClickOutside(
() => dropdownRef.value?.close(),
);
// Reload models when credentials are updated
// Update agents when credentials are updated
watch(
() => credentials,
(credentials) => {
async (credentials) => {
if (credentials) {
void chatStore.fetchAgents(credentials);
agents.value = await fetchChatModelsApi(useRootStore().restApiContext, { credentials });
}
},
{ immediate: true },
@ -199,12 +208,7 @@ defineExpose({
</script>
<template>
<N8nNavigationDropdown
v-if="chatStore.agentsReady"
ref="dropdownRef"
: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"
@ -213,7 +217,7 @@ defineExpose({
:class="$style.menuIcon"
/>
<N8nAvatar
v-else-if="item.id.startsWith('custom-agent::')"
v-else-if="item.id.startsWith('n8n::') || item.id.startsWith('custom-agent::')"
:class="$style.avatarIcon"
:first-name="item.title"
size="xsmall"