feat(core): Support attaching tools to custom builder agents (no-changelog) (#21550)

This commit is contained in:
Jaakko Husso 2025-11-17 16:06:58 +02:00 committed by GitHub
parent 8e5e5965b1
commit 08a2ceaddf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 603 additions and 541 deletions

View File

@ -308,6 +308,7 @@
"chatHub.agent.editor.systemPrompt.label": "System Prompt",
"chatHub.agent.editor.systemPrompt.placeholder": "Enter system prompt",
"chatHub.agent.editor.model.label": "Model",
"chatHub.agent.editor.tools.label": "Tools",
"chatHub.agent.editor.loading": "Loading agent...",
"chatHub.agent.editor.saving": "Saving...",
"chatHub.agent.editor.save": "Save",

View File

@ -14,7 +14,6 @@ import {
} from '@n8n/design-system';
import { computed, ref, watch } from 'vue';
import { useUIStore } from '@/app/stores/ui.store';
import AgentEditorModal from '@/features/ai/chatHub/components/AgentEditorModal.vue';
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';
@ -23,7 +22,7 @@ import { filterAndSortAgents, stringifyModel } from '@/features/ai/chatHub/chat.
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 { AGENT_EDITOR_MODAL_KEY, MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants';
import { useRouter } from 'vue-router';
const chatStore = useChatStore();
@ -35,8 +34,6 @@ const sidebar = useChatHubSidebarState();
const router = useRouter();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const editingAgentId = ref<string | undefined>(undefined);
const agentFilter = ref<ChatAgentFilter>({
search: '',
provider: '',
@ -65,8 +62,12 @@ const sortOptions = [
function handleCreateAgent() {
chatStore.currentEditingAgent = null;
editingAgentId.value = undefined;
uiStore.openModal('agentEditor');
uiStore.openModalWithData({
name: AGENT_EDITOR_MODAL_KEY,
data: {
credentials: credentialsByProvider,
},
});
}
async function handleEditAgent(model: ChatHubConversationModel) {
@ -85,22 +86,19 @@ async function handleEditAgent(model: ChatHubConversationModel) {
if (model.provider === 'custom-agent') {
try {
await chatStore.fetchCustomAgent(model.agentId);
editingAgentId.value = model.agentId;
uiStore.openModal('agentEditor');
uiStore.openModalWithData({
name: AGENT_EDITOR_MODAL_KEY,
data: {
agentId: model.agentId,
credentials: credentialsByProvider,
},
});
} catch (error) {
toast.showError(error, 'Failed to load agent');
}
}
}
function handleCloseAgentEditor() {
editingAgentId.value = undefined;
}
async function handleAgentCreatedOrUpdated() {
editingAgentId.value = undefined;
}
async function handleDeleteAgent(agentId: string) {
const confirmed = await message.confirm(
'Are you sure you want to delete this agent?',
@ -201,14 +199,6 @@ watch(
/>
</div>
<AgentEditorModal
v-if="credentialsByProvider"
:agent-id="editingAgentId"
:credentials="credentialsByProvider"
@create-custom-agent="handleAgentCreatedOrUpdated"
@close="handleCloseAgentEditor"
/>
<N8nIconButton
v-if="!sidebar.isStatic.value"
:class="$style.menuButton"

View File

@ -10,8 +10,8 @@ import ChatConversationHeader from '@/features/ai/chatHub/components/ChatConvers
import ChatMessage from '@/features/ai/chatHub/components/ChatMessage.vue';
import ChatPrompt from '@/features/ai/chatHub/components/ChatPrompt.vue';
import ChatStarter from '@/features/ai/chatHub/components/ChatStarter.vue';
import AgentEditorModal from '@/features/ai/chatHub/components/AgentEditorModal.vue';
import {
AGENT_EDITOR_MODAL_KEY,
CHAT_CONVERSATION_VIEW,
CHAT_VIEW,
MOBILE_MEDIA_QUERY,
@ -36,7 +36,6 @@ import { useChatStore } from './chat.store';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useUIStore } from '@/app/stores/ui.store';
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
import ToolsSelector from './components/ToolsSelector.vue';
import { INodesSchema, type INode } from 'n8n-workflow';
const router = useRouter();
@ -65,6 +64,13 @@ const currentConversation = computed(() =>
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 { arrivedState, measure } = useScroll(scrollContainerRef, {
throttle: 100,
offset: { bottom: 100 },
@ -199,8 +205,6 @@ const isMissingSelectedCredential = computed(() => !credentialsForSelectedProvid
const editingMessageId = ref<string>();
const didSubmitInCurrentSession = ref(false);
const editingAgentId = ref<string | undefined>(undefined);
const isToolsSelectorOpen = ref(false);
function scrollToBottom(smooth: boolean) {
scrollContainerRef.value?.scrollTo({
@ -316,7 +320,7 @@ function onSubmit(message: string) {
message,
selectedModel.value.model,
credentialsForSelectedProvider.value,
selectedTools.value,
canSelectTools.value ? selectedTools.value : [],
);
inputRef.value?.setText('');
@ -405,12 +409,7 @@ function handleConfigureModel() {
headerRef.value?.openModelSelector();
}
function handleConfigureTools() {
isToolsSelectorOpen.value = true;
uiStore.openModal('toolsSelector');
}
async function onUpdateTools(newTools: INode[]) {
async function handleUpdateTools(newTools: INode[]) {
toolsSelection.value = newTools;
defaultTools.value = newTools;
@ -426,8 +425,15 @@ async function onUpdateTools(newTools: INode[]) {
async function handleEditAgent(agentId: string) {
try {
await chatStore.fetchCustomAgent(agentId);
editingAgentId.value = agentId;
uiStore.openModal('agentEditor');
uiStore.openModalWithData({
name: AGENT_EDITOR_MODAL_KEY,
data: {
agentId,
credentials: credentialsByProvider,
onCreateCustomAgent: handleSelectModel,
},
});
} catch (error) {
toast.showError(error, 'Failed to load agent');
}
@ -435,12 +441,13 @@ async function handleEditAgent(agentId: string) {
function openNewAgentCreator() {
chatStore.currentEditingAgent = null;
editingAgentId.value = undefined;
uiStore.openModal('agentEditor');
}
function closeAgentEditor() {
editingAgentId.value = undefined;
uiStore.openModalWithData({
name: AGENT_EDITOR_MODAL_KEY,
data: {
credentials: credentialsByProvider,
onCreateCustomAgent: handleSelectModel,
},
});
}
function handleOpenWorkflow(workflowId: string) {
@ -472,20 +479,6 @@ function handleOpenWorkflow(workflowId: string) {
@open-workflow="handleOpenWorkflow"
/>
<AgentEditorModal
v-if="credentialsByProvider"
:agent-id="editingAgentId"
:credentials="credentialsByProvider"
@create-custom-agent="handleSelectModel"
@close="closeAgentEditor"
/>
<ToolsSelector
v-if="isToolsSelectorOpen"
:initial-value="selectedTools"
@update="onUpdateTools"
/>
<N8nScrollArea
v-if="readyToShowMessages"
type="scroll"
@ -538,15 +531,16 @@ function handleOpenWorkflow(workflowId: string) {
<ChatPrompt
ref="inputRef"
:class="$style.prompt"
:is-responding="isResponding"
:selected-model="selectedModel ?? null"
:selected-tools="selectedTools"
:is-responding="isResponding"
:is-tools-selectable="canSelectTools"
:is-missing-credentials="isMissingSelectedCredential"
:is-new-session="isNewSession"
@submit="onSubmit"
@stop="onStop"
@select-model="handleConfigureModel"
@select-tools="handleConfigureTools"
@select-tools="handleUpdateTools"
@set-credentials="handleConfigureCredentials"
/>
</div>

View File

@ -4,27 +4,27 @@ 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 { useUIStore } from '@/app/stores/ui.store';
import type { ChatHubProvider, ChatModelDto } from '@n8n/api-types';
import { N8nButton, N8nHeading, N8nInput, N8nInputLabel } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { assert } from '@n8n/utils/assert';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed, ref, watch } from 'vue';
import { computed, onMounted, ref } from 'vue';
import type { CredentialsMap } from '../chat.types';
import type { INode } from 'n8n-workflow';
import ToolsSelector from './ToolsSelector.vue';
const props = defineProps<{
credentials: CredentialsMap;
agentId?: string;
}>();
const emit = defineEmits<{
createCustomAgent: [agent: ChatModelDto];
close: [];
modalName: string;
data: {
agentId?: string;
credentials: CredentialsMap;
onClose?: () => void;
onCreateCustomAgent?: (selection: ChatModelDto) => void;
};
}>();
const chatStore = useChatStore();
const uiStore = useUIStore();
const i18n = useI18n();
const toast = useToast();
const message = useMessage();
@ -36,10 +36,11 @@ const systemPrompt = ref('');
const selectedModel = ref<ChatModelDto | null>(null);
const isSaving = ref(false);
const isDeleting = ref(false);
const tools = ref<INode[]>([]);
const agentSelectedCredentials = ref<CredentialsMap>({});
const isEditMode = computed(() => !!props.agentId);
const isEditMode = computed(() => !!props.data.agentId);
const title = computed(() =>
isEditMode.value
? i18n.baseText('chatHub.agent.editor.title.edit')
@ -61,7 +62,7 @@ const isValid = computed(() => {
const agentMergedCredentials = computed((): CredentialsMap => {
return {
...props.credentials,
...props.data.credentials,
...agentSelectedCredentials.value,
};
});
@ -75,33 +76,18 @@ function loadAgent() {
description.value = customAgent.description ?? '';
systemPrompt.value = customAgent.systemPrompt;
selectedModel.value = chatStore.getAgent(customAgent) ?? null;
tools.value = customAgent.tools || [];
if (customAgent.credentialId) {
agentSelectedCredentials.value[customAgent.provider] = customAgent.credentialId;
}
}
function resetForm() {
name.value = '';
description.value = '';
systemPrompt.value = '';
selectedModel.value = null;
agentSelectedCredentials.value = {};
}
// Watch for modal opening
watch(
() => uiStore.modalsById.agentEditor?.open,
(isOpen) => {
if (isOpen) {
if (props.agentId) {
loadAgent();
} else {
resetForm();
}
}
},
);
onMounted(() => {
if (props.data.agentId) {
loadAgent();
}
});
function onCredentialSelected(provider: ChatHubProvider, credentialId: string) {
agentSelectedCredentials.value = {
@ -136,18 +122,18 @@ async function onSave() {
systemPrompt: systemPrompt.value.trim(),
...model,
credentialId,
tools: [],
tools: tools.value,
};
if (isEditMode.value && props.agentId) {
await chatStore.updateCustomAgent(props.agentId, payload, props.credentials);
if (isEditMode.value && props.data.agentId) {
await chatStore.updateCustomAgent(props.data.agentId, payload, props.data.credentials);
toast.showMessage({
title: i18n.baseText('chatHub.agent.editor.success.update'),
type: 'success',
});
} else {
const agent = await chatStore.createCustomAgent(payload, props.credentials);
emit('createCustomAgent', agent);
const agent = await chatStore.createCustomAgent(payload, props.data.credentials);
props.data.onCreateCustomAgent?.(agent);
toast.showMessage({
title: i18n.baseText('chatHub.agent.editor.success.create'),
@ -165,7 +151,7 @@ async function onSave() {
}
async function onDelete() {
if (!isEditMode.value || !props.agentId || isDeleting.value) return;
if (!isEditMode.value || !props.data.agentId || isDeleting.value) return;
const confirmed = await message.confirm(
i18n.baseText('chatHub.agent.editor.delete.confirm.message'),
@ -181,12 +167,12 @@ async function onDelete() {
isDeleting.value = true;
try {
await chatStore.deleteCustomAgent(props.agentId, props.credentials);
await chatStore.deleteCustomAgent(props.data.agentId, props.data.credentials);
toast.showMessage({
title: i18n.baseText('chatHub.agent.editor.success.delete'),
type: 'success',
});
emit('close');
props.data.onClose?.();
modalBus.value.emit('close');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '';
@ -195,11 +181,15 @@ async function onDelete() {
isDeleting.value = false;
}
}
function onSelectTools(newTools: INode[]) {
tools.value = newTools;
}
</script>
<template>
<Modal
name="agentEditor"
:name="modalName"
:event-bus="modalBus"
width="600px"
:center="true"
@ -255,19 +245,33 @@ async function onDelete() {
/>
</N8nInputLabel>
<N8nInputLabel
input-name="agent-model"
:label="i18n.baseText('chatHub.agent.editor.model.label')"
:required="true"
>
<ModelSelector
:selectedAgent="selectedModel"
:include-custom-agents="false"
:credentials="agentMergedCredentials"
@change="onModelChange"
@select-credential="onCredentialSelected"
/>
</N8nInputLabel>
<div :class="$style.row">
<N8nInputLabel
input-name="agent-model"
:class="$style.input"
:label="i18n.baseText('chatHub.agent.editor.model.label')"
:required="true"
>
<ModelSelector
:selected-agent="selectedModel"
:include-custom-agents="false"
:credentials="agentMergedCredentials"
@change="onModelChange"
@select-credential="onCredentialSelected"
/>
</N8nInputLabel>
<N8nInputLabel
input-name="agent-model"
:class="$style.input"
:label="i18n.baseText('chatHub.agent.editor.tools.label')"
:required="false"
>
<div>
<ToolsSelector :disabled="false" :selected="tools" @select="onSelectTools" />
</div>
</N8nInputLabel>
</div>
</div>
</template>
<template #footer>
@ -302,6 +306,11 @@ async function onDelete() {
width: 100%;
}
.row {
display: flex;
flex-direction: row;
}
.footer {
display: flex;
justify-content: flex-end;

View File

@ -1,34 +1,33 @@
<script setup lang="ts">
import { useToast } from '@/app/composables/useToast';
import NodeIcon from '@/app/components/NodeIcon.vue';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { ChatHubLLMProvider, ChatModelDto } from '@n8n/api-types';
import { N8nButton, N8nIcon, N8nIconButton, N8nInput, N8nText } from '@n8n/design-system';
import { N8nIconButton, N8nInput, N8nText } from '@n8n/design-system';
import { useSpeechRecognition } from '@vueuse/core';
import type { INode } from 'n8n-workflow';
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
import ToolsSelector from './ToolsSelector.vue';
const { selectedModel, selectedTools, isMissingCredentials } = defineProps<{
isResponding: boolean;
isNewSession: boolean;
isToolsSelectable: boolean;
isMissingCredentials: boolean;
selectedModel: ChatModelDto | null;
selectedTools: INode[] | null;
isMissingCredentials: boolean;
}>();
const emit = defineEmits<{
submit: [string];
stop: [];
selectModel: [];
selectTools: [];
selectTools: [INode[]];
setCredentials: [ChatHubLLMProvider];
}>();
const inputRef = useTemplateRef<HTMLElement>('inputRef');
const message = ref('');
const nodeTypesStore = useNodeTypesStore();
const toast = useToast();
const speechInput = useSpeechRecognition({
@ -101,28 +100,10 @@ watch(speechInput.error, (event) => {
}
});
function onSelectTools() {
emit('selectTools');
function onSelectTools(tools: INode[]) {
emit('selectTools', tools);
}
onMounted(async () => {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
});
const toolCount = computed(() => selectedTools?.length ?? 0);
const displayToolNodeTypes = computed(() => {
const tools = selectedTools ?? [];
return tools
.slice(0, 3)
.map((t) => nodeTypesStore.getNodeType(t.type))
.filter(Boolean);
});
const toolsLabel = computed(() =>
toolCount.value > 0 ? `${toolCount.value} Tool${toolCount.value > 1 ? 's' : ''}` : 'Tools',
);
defineExpose({
focus: () => inputRef.value?.focus(),
setText: (text: string) => {
@ -169,30 +150,12 @@ defineExpose({
@keydown="handleKeydownTextarea"
/>
<div :class="$style.tools">
<N8nButton
:class="$style.toolsButton"
<div v-if="isToolsSelectable" :class="$style.tools">
<ToolsSelector
:selected="selectedTools ?? []"
:disabled="isMissingCredentials || !selectedModel || isResponding"
aria-label="Select tools"
@click="onSelectTools"
>
<span v-if="toolCount" :class="$style.iconStack" aria-hidden="true">
<NodeIcon
v-for="(nodeType, i) in displayToolNodeTypes"
:key="`${nodeType?.name}-${i}`"
:style="{ zIndex: displayToolNodeTypes.length - i }"
:node-type="nodeType"
:class="[$style.icon, { [$style.iconOverlap]: i !== 0 }]"
:circle="true"
:size="12"
/>
</span>
<span v-else :class="$style.iconFallback" aria-hidden="true">
<N8nIcon icon="plus" :size="12" />
</span>
<N8nText size="small" bold>{{ toolsLabel }}</N8nText>
</N8nButton>
@select="onSelectTools"
/>
</div>
<div :class="$style.actions">
@ -293,47 +256,6 @@ defineExpose({
gap: var(--spacing--2xs);
}
.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;
}
}
.iconStack {
display: flex;
align-items: center;
position: relative;
}
.icon {
padding: var(--spacing--4xs);
background-color: var(--button--color--background--secondary);
border-radius: 50%;
outline: 2px var(--color--background--light-3) solid;
}
.iconOverlap {
margin-left: -6px;
}
.iconFallback {
display: flex;
align-items: center;
justify-content: center;
}
/* Right-side actions */
.actions {
position: absolute;

View File

@ -1,375 +1,110 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { N8nButton, N8nHeading, N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import Modal from '@/app/components/Modal.vue';
import NodeIcon from '@/app/components/NodeIcon.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 ChatHubAgentTool } from '@n8n/api-types';
import {
type INode,
deepCopy,
JINA_AI_TOOL_NODE_TYPE,
SEAR_XNG_TOOL_NODE_TYPE,
} from 'n8n-workflow';
import { AVAILABLE_TOOLS, type ChatHubToolProvider } from '../composables/availableTools';
import { ElSwitch } from 'element-plus';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/app/stores/ui.store';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import type { INode } from 'n8n-workflow';
import { computed, onMounted } from 'vue';
import { TOOLS_SELECTOR_MODAL_KEY } from '../constants';
const { initialValue } = defineProps<{
initialValue: INode[] | null;
const { selected } = defineProps<{
disabled: boolean;
selected: INode[];
}>();
const emit = defineEmits<{
update: [INode[]];
select: [INode[]];
}>();
const i18n = useI18n();
const modalBus = ref(createEventBus());
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const selectedByProvider = ref<Record<ChatHubAgentTool, Set<string>>>({
[JINA_AI_TOOL_NODE_TYPE]: new Set(),
[SEAR_XNG_TOOL_NODE_TYPE]: new Set(),
const toolCount = computed(() => selected.length ?? 0);
const displayToolNodeTypes = computed(() => {
return selected
.slice(0, 3)
.map((t) => nodeTypesStore.getNodeType(t.type))
.filter(Boolean);
});
const credentialIdByProvider = ref<Record<ChatHubAgentTool, string | null>>({
[JINA_AI_TOOL_NODE_TYPE]: null,
[SEAR_XNG_TOOL_NODE_TYPE]: null,
});
function resetSelections() {
selectedByProvider.value = {
[JINA_AI_TOOL_NODE_TYPE]: new Set(),
[SEAR_XNG_TOOL_NODE_TYPE]: new Set(),
};
credentialIdByProvider.value = {
[JINA_AI_TOOL_NODE_TYPE]: null,
[SEAR_XNG_TOOL_NODE_TYPE]: null,
};
}
function restoreFromInitial(nodes: INode[]) {
resetSelections();
const toolsByProvider = new Map<ChatHubAgentTool, Set<string>>();
Object.entries(AVAILABLE_TOOLS).forEach(([key, p]) => {
toolsByProvider.set(key as ChatHubAgentTool, new Set(p.tools.map((t) => t.node.name)));
});
for (const node of nodes) {
const providerKey = node.type as ChatHubAgentTool;
const provider = AVAILABLE_TOOLS[providerKey];
if (!provider) continue;
const isValid = toolsByProvider.get(providerKey)?.has(node.name);
if (!isValid) continue;
selectedByProvider.value[providerKey].add(node.name);
if (provider.credentialType) {
const credentialId = node.credentials?.[provider.credentialType].id;
if (
credentialId &&
typeof credentialId === 'string' &&
!credentialIdByProvider.value[providerKey]
) {
credentialIdByProvider.value[providerKey] = credentialId;
}
}
}
selectedByProvider.value = { ...selectedByProvider.value };
credentialIdByProvider.value = { ...credentialIdByProvider.value };
}
function getAvailableCredentials(toolNodeType: ChatHubAgentTool): ICredentialsResponse[] {
const provider = AVAILABLE_TOOLS[toolNodeType];
if (!provider.credentialType) return [];
return credentialsStore.getCredentialsByType(provider.credentialType);
}
const providers = computed<Array<[ChatHubAgentTool, ChatHubToolProvider]>>(() => {
return Object.entries(AVAILABLE_TOOLS) as Array<[ChatHubAgentTool, ChatHubToolProvider]>;
});
function getSelectedCount(): number {
return providers.value.reduce(
(acc, [key]) => acc + (selectedByProvider.value[key]?.size ?? 0),
0,
);
}
const getNodeIcon = (nodeType: ChatHubAgentTool) => {
return nodeTypesStore.getNodeType(nodeType);
};
function toggleTool(
providerKey: ChatHubAgentTool,
toolId: string,
value: string | number | boolean,
) {
const enabled = typeof value === 'boolean' ? value : Boolean(value);
if (!selectedByProvider.value[providerKey]) {
selectedByProvider.value[providerKey] = new Set();
}
const set = selectedByProvider.value[providerKey];
if (enabled) {
set.add(toolId);
} else {
set.delete(toolId);
}
selectedByProvider.value = { ...selectedByProvider.value };
}
function onCredentialSelect(providerKey: ChatHubAgentTool, id: string) {
credentialIdByProvider.value[providerKey] = id;
}
function onCreateNewCredential(providerKey: ChatHubAgentTool) {
const provider = AVAILABLE_TOOLS[providerKey];
if (!provider.credentialType) return;
uiStore.openNewCredential(provider.credentialType);
}
const isMissingCredentials = computed(() => {
for (const [providerKey, provider] of providers.value) {
const selectedIds = selectedByProvider.value[providerKey];
if (!selectedIds || selectedIds.size === 0) {
continue;
}
// If provider requires credential, ensure one is selected
if (provider.credentialType) {
const selectedCredential = credentialIdByProvider.value[providerKey];
if (!selectedCredential) {
return true;
}
}
}
return false;
});
function onConfirm() {
const tools: INode[] = [];
for (const [providerKey, provider] of providers.value) {
const selected = selectedByProvider.value[providerKey];
if (!selected || selected.size === 0) continue;
const pickedId = credentialIdByProvider.value[providerKey] ?? null;
let credential: ICredentialsResponse | undefined;
if (provider.credentialType && pickedId) {
credential = getAvailableCredentials(providerKey).find((c) => c.id === pickedId);
}
for (const toolName of selected) {
const tool = provider.tools.find((t) => t.node.name === toolName);
if (!tool) continue;
const node = deepCopy(tool.node);
// If the provider defines a credentialType and user chose a credential, attach/override it
if (provider.credentialType && credential) {
node.credentials = node.credentials ?? {};
node.credentials[provider.credentialType] = { id: credential.id, name: credential.name };
}
tools.push(node);
}
}
emit('update', tools);
modalBus.value.emit('close');
}
function onCancel() {
modalBus.value.emit('close');
}
watch(
() => initialValue,
(nodes: INode[] | null) => {
if (nodes?.length) {
restoreFromInitial(nodes);
} else {
resetSelections();
}
},
{ immediate: true },
const toolsLabel = computed(() =>
toolCount.value > 0 ? `${toolCount.value} Tool${toolCount.value > 1 ? 's' : ''}` : 'Tools',
);
onMounted(async () => {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
});
const handleSelect = (tools: INode[]) => {
emit('select', tools);
};
const onClick = () => {
uiStore.openModalWithData({
name: TOOLS_SELECTOR_MODAL_KEY,
data: { selected, onConfirm: handleSelect },
});
};
</script>
<template>
<Modal
name="toolsSelector"
:event-bus="modalBus"
width="50%"
max-width="720px"
min-height="340px"
:center="true"
<N8nButton
type="secondary"
:class="$style.toolsButton"
:disabled="disabled"
aria-label="Select tools"
@click="onClick"
>
<template #header>
<div :class="$style.header">
<N8nIcon icon="settings2" :size="24" />
<N8nHeading size="large" color="text-dark">{{
i18n.baseText('chatHub.tools.editor.title')
}}</N8nHeading>
</div>
</template>
<span v-if="toolCount" :class="$style.iconStack">
<NodeIcon
v-for="(nodeType, i) in displayToolNodeTypes"
:key="`${nodeType?.name}-${i}`"
:style="{ zIndex: displayToolNodeTypes.length - i }"
:node-type="nodeType"
:class="[$style.icon, { [$style.iconOverlap]: i !== 0 }]"
:circle="true"
:size="12"
/>
</span>
<span v-else :class="$style.iconFallback">
<N8nIcon icon="plus" :size="12" />
</span>
<template #content>
<div :class="$style.content">
<div v-for="[key, provider] in providers" :key="key" :class="$style.provider">
<div :class="$style.providerHeader">
<div :class="$style.providerTitle">
<NodeIcon :node-type="getNodeIcon(key)" :size="18" />
<N8nHeading color="text-dark" size="medium">{{ provider.name }}</N8nHeading>
</div>
<N8nText size="small" color="text-base">{{ provider.description }}</N8nText>
</div>
<div v-if="provider.credentialType" :class="$style.row">
<N8nText size="small" color="text-base">{{
i18n.baseText('chatHub.tools.editor.credential')
}}</N8nText>
<div :class="$style.credentials">
<N8nSelect
:model-value="credentialIdByProvider[key] ?? null"
size="large"
:placeholder="i18n.baseText('chatHub.tools.editor.credential.placeholder')"
@update:model-value="onCredentialSelect(key, $event)"
>
<N8nOption
v-for="c in getAvailableCredentials(key)"
:key="c.id"
:value="c.id"
:label="c.name"
/>
</N8nSelect>
<N8nButton size="medium" type="secondary" @click="onCreateNewCredential(key)">
{{ i18n.baseText('chatHub.tools.editor.credential.new') }}
</N8nButton>
</div>
</div>
<div :class="$style.toolsList">
<div v-for="tool in provider.tools" :key="tool.node.id" :class="$style.toolRow">
<div :class="$style.toolInfo">
<N8nText size="medium" bold color="text-base">{{
tool.title || tool.node.name
}}</N8nText>
<N8nText size="small" color="text-base">
{{ tool.node.name }}
</N8nText>
</div>
<ElSwitch
size="large"
:aria-label="`Toggle ${tool.title || tool.node.name}`"
:model-value="!!selectedByProvider[key]?.has(tool.node.name)"
@update:model-value="(val) => toggleTool(key, tool.node.name, val)"
/>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nText color="text-base">
{{
i18n.baseText('chatHub.tools.editor.selectedCount', {
interpolate: { count: getSelectedCount() },
})
}}
</N8nText>
<div :class="$style.footerRight">
<N8nButton type="tertiary" @click="onCancel">{{
i18n.baseText('chatHub.tools.editor.cancel')
}}</N8nButton>
<N8nButton type="primary" :disabled="isMissingCredentials" @click="onConfirm">{{
i18n.baseText('chatHub.tools.editor.confirm')
}}</N8nButton>
</div>
</div>
</template>
</Modal>
<N8nText size="small" bold color="text-dark">{{ toolsLabel }}</N8nText>
</N8nButton>
</template>
<style lang="scss" module>
.header {
.toolsButton {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
padding: var(--spacing--sm) 0 var(--spacing--md);
}
.provider {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
margin-bottom: var(--spacing--md);
}
.providerHeader {
display: grid;
gap: var(--spacing--2xs);
}
.providerTitle {
display: inline-flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--4xs) var(--spacing--2xs);
background-color: transparent;
}
.row {
display: grid;
gap: var(--spacing--2xs);
}
.credentials {
.iconStack {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
position: relative;
}
.toolsList {
display: grid;
gap: var(--spacing--sm);
}
.toolRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--2xs) 0;
}
.toolInfo {
display: grid;
gap: 2px;
.icon {
padding: var(--spacing--4xs);
background-color: var(--color--background--light-2);
border-radius: 50%;
outline: 2px var(--color--background--light-3) solid;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.iconOverlap {
margin-left: -6px;
}
.footerRight {
.iconFallback {
padding: var(--spacing--4xs);
display: flex;
gap: var(--spacing--2xs);
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,378 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { N8nButton, N8nHeading, N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import Modal from '@/app/components/Modal.vue';
import NodeIcon from '@/app/components/NodeIcon.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 ChatHubAgentTool } from '@n8n/api-types';
import {
type INode,
deepCopy,
JINA_AI_TOOL_NODE_TYPE,
SEAR_XNG_TOOL_NODE_TYPE,
} from 'n8n-workflow';
import { AVAILABLE_TOOLS, type ChatHubToolProvider } from '../composables/availableTools';
import { ElSwitch } from 'element-plus';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useI18n } from '@n8n/i18n';
const props = defineProps<{
modalName: string;
data: {
selected: INode[];
onConfirm: (tools: INode[]) => void;
};
}>();
const i18n = useI18n();
const modalBus = ref(createEventBus());
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const selectedByProvider = ref<Record<ChatHubAgentTool, Set<string>>>({
[JINA_AI_TOOL_NODE_TYPE]: new Set(),
[SEAR_XNG_TOOL_NODE_TYPE]: new Set(),
});
const credentialIdByProvider = ref<Record<ChatHubAgentTool, string | null>>({
[JINA_AI_TOOL_NODE_TYPE]: null,
[SEAR_XNG_TOOL_NODE_TYPE]: null,
});
function resetSelections() {
selectedByProvider.value = {
[JINA_AI_TOOL_NODE_TYPE]: new Set(),
[SEAR_XNG_TOOL_NODE_TYPE]: new Set(),
};
credentialIdByProvider.value = {
[JINA_AI_TOOL_NODE_TYPE]: null,
[SEAR_XNG_TOOL_NODE_TYPE]: null,
};
}
function restoreFromInitial(nodes: INode[]) {
resetSelections();
const toolsByProvider = new Map<ChatHubAgentTool, Set<string>>();
Object.entries(AVAILABLE_TOOLS).forEach(([key, p]) => {
toolsByProvider.set(key as ChatHubAgentTool, new Set(p.tools.map((t) => t.node.name)));
});
for (const node of nodes) {
const providerKey = node.type as ChatHubAgentTool;
const provider = AVAILABLE_TOOLS[providerKey];
if (!provider) continue;
const isValid = toolsByProvider.get(providerKey)?.has(node.name);
if (!isValid) continue;
selectedByProvider.value[providerKey].add(node.name);
if (provider.credentialType) {
const credentialId = node.credentials?.[provider.credentialType].id;
if (
credentialId &&
typeof credentialId === 'string' &&
!credentialIdByProvider.value[providerKey]
) {
credentialIdByProvider.value[providerKey] = credentialId;
}
}
}
selectedByProvider.value = { ...selectedByProvider.value };
credentialIdByProvider.value = { ...credentialIdByProvider.value };
}
function getAvailableCredentials(toolNodeType: ChatHubAgentTool): ICredentialsResponse[] {
const provider = AVAILABLE_TOOLS[toolNodeType];
if (!provider.credentialType) return [];
return credentialsStore.getCredentialsByType(provider.credentialType);
}
const providers = computed<Array<[ChatHubAgentTool, ChatHubToolProvider]>>(() => {
return Object.entries(AVAILABLE_TOOLS) as Array<[ChatHubAgentTool, ChatHubToolProvider]>;
});
const selectedCount = computed(() => {
return providers.value.reduce(
(acc, [key]) => acc + (selectedByProvider.value[key]?.size ?? 0),
0,
);
});
const getNodeIcon = (nodeType: ChatHubAgentTool) => {
return nodeTypesStore.getNodeType(nodeType);
};
function toggleTool(
providerKey: ChatHubAgentTool,
toolId: string,
value: string | number | boolean,
) {
const enabled = typeof value === 'boolean' ? value : Boolean(value);
if (!selectedByProvider.value[providerKey]) {
selectedByProvider.value[providerKey] = new Set();
}
const set = selectedByProvider.value[providerKey];
if (enabled) {
set.add(toolId);
} else {
set.delete(toolId);
}
selectedByProvider.value = { ...selectedByProvider.value };
}
function onCredentialSelect(providerKey: ChatHubAgentTool, id: string) {
credentialIdByProvider.value[providerKey] = id;
}
function onCreateNewCredential(providerKey: ChatHubAgentTool) {
const provider = AVAILABLE_TOOLS[providerKey];
if (!provider.credentialType) return;
uiStore.openNewCredential(provider.credentialType);
}
const isMissingCredentials = computed(() => {
for (const [providerKey, provider] of providers.value) {
const selectedIds = selectedByProvider.value[providerKey];
if (!selectedIds || selectedIds.size === 0) {
continue;
}
// If provider requires credential, ensure one is selected
if (provider.credentialType) {
const selectedCredential = credentialIdByProvider.value[providerKey];
if (!selectedCredential) {
return true;
}
}
}
return false;
});
function handleConfirm() {
const tools: INode[] = [];
for (const [providerKey, provider] of providers.value) {
const selected = selectedByProvider.value[providerKey];
if (!selected || selected.size === 0) continue;
const pickedId = credentialIdByProvider.value[providerKey] ?? null;
let credential: ICredentialsResponse | undefined;
if (provider.credentialType && pickedId) {
credential = getAvailableCredentials(providerKey).find((c) => c.id === pickedId);
}
for (const toolName of selected) {
const tool = provider.tools.find((t) => t.node.name === toolName);
if (!tool) continue;
const node = deepCopy(tool.node);
// If the provider defines a credentialType and user chose a credential, attach/override it
if (provider.credentialType && credential) {
node.credentials = node.credentials ?? {};
node.credentials[provider.credentialType] = { id: credential.id, name: credential.name };
}
tools.push(node);
}
}
props.data.onConfirm(tools);
modalBus.value.emit('close');
}
function onCancel() {
modalBus.value.emit('close');
}
watch(
() => props.data.selected,
(toolNodes: INode[]) => {
if (toolNodes?.length > 0) {
restoreFromInitial(toolNodes);
} else {
resetSelections();
}
},
{ immediate: true },
);
onMounted(async () => {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
});
</script>
<template>
<Modal
:name="modalName"
:event-bus="modalBus"
width="50%"
max-width="720px"
min-height="340px"
:center="true"
>
<template #header>
<div :class="$style.header">
<N8nIcon icon="settings2" :size="24" />
<N8nHeading size="large" color="text-dark">{{
i18n.baseText('chatHub.tools.editor.title')
}}</N8nHeading>
</div>
</template>
<template #content>
<div :class="$style.content">
<div v-for="[key, provider] in providers" :key="key" :class="$style.provider">
<div :class="$style.providerHeader">
<div :class="$style.providerTitle">
<NodeIcon :node-type="getNodeIcon(key)" :size="18" />
<N8nHeading color="text-dark" size="medium">{{ provider.name }}</N8nHeading>
</div>
<N8nText size="small" color="text-base">{{ provider.description }}</N8nText>
</div>
<div v-if="provider.credentialType" :class="$style.row">
<N8nText size="small" color="text-base">{{
i18n.baseText('chatHub.tools.editor.credential')
}}</N8nText>
<div :class="$style.credentials">
<N8nSelect
:model-value="credentialIdByProvider[key] ?? null"
size="large"
:placeholder="i18n.baseText('chatHub.tools.editor.credential.placeholder')"
@update:model-value="onCredentialSelect(key, $event)"
>
<N8nOption
v-for="c in getAvailableCredentials(key)"
:key="c.id"
:value="c.id"
:label="c.name"
/>
</N8nSelect>
<N8nButton size="medium" type="secondary" @click="onCreateNewCredential(key)">
{{ i18n.baseText('chatHub.tools.editor.credential.new') }}
</N8nButton>
</div>
</div>
<div :class="$style.toolsList">
<div v-for="tool in provider.tools" :key="tool.node.id" :class="$style.toolRow">
<div :class="$style.toolInfo">
<N8nText size="medium" bold color="text-base">{{
tool.title || tool.node.name
}}</N8nText>
<N8nText size="small" color="text-base">
{{ tool.node.name }}
</N8nText>
</div>
<ElSwitch
size="large"
:aria-label="`Toggle ${tool.title || tool.node.name}`"
:model-value="!!selectedByProvider[key]?.has(tool.node.name)"
@update:model-value="
(val: string | number | boolean) => toggleTool(key, tool.node.name, val)
"
/>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nText color="text-base">
{{
i18n.baseText('chatHub.tools.editor.selectedCount', {
interpolate: { count: selectedCount },
})
}}
</N8nText>
<div :class="$style.footerRight">
<N8nButton type="tertiary" @click="onCancel">{{
i18n.baseText('chatHub.tools.editor.cancel')
}}</N8nButton>
<N8nButton type="primary" :disabled="isMissingCredentials" @click="handleConfirm">{{
i18n.baseText('chatHub.tools.editor.confirm')
}}</N8nButton>
</div>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.header {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
padding: var(--spacing--sm) 0 var(--spacing--md);
}
.provider {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
margin-bottom: var(--spacing--md);
}
.providerHeader {
display: grid;
gap: var(--spacing--2xs);
}
.providerTitle {
display: inline-flex;
align-items: center;
gap: var(--spacing--2xs);
}
.row {
display: grid;
gap: var(--spacing--2xs);
}
.credentials {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
.toolsList {
display: grid;
gap: var(--spacing--sm);
}
.toolRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--2xs) 0;
}
.toolInfo {
display: grid;
gap: 2px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.footerRight {
display: flex;
gap: var(--spacing--2xs);
}
</style>

View File

@ -16,3 +16,6 @@ export const providerDisplayNames: Record<ChatHubProvider, string> = {
};
export const MOBILE_MEDIA_QUERY = '(max-width: 768px)';
export const TOOLS_SELECTOR_MODAL_KEY = 'toolsSelectorModal';
export const AGENT_EDITOR_MODAL_KEY = 'agentEditorModal';

View File

@ -1,5 +1,11 @@
import { type FrontendModuleDescription } from '@/app/moduleInitializer/module.types';
import { CHAT_VIEW, CHAT_CONVERSATION_VIEW, CHAT_AGENTS_VIEW } from './constants';
import {
CHAT_VIEW,
CHAT_CONVERSATION_VIEW,
CHAT_AGENTS_VIEW,
TOOLS_SELECTOR_MODAL_KEY,
AGENT_EDITOR_MODAL_KEY,
} from '@/features/ai/chatHub/constants';
const ChatSidebar = async () => await import('@/features/ai/chatHub/components/ChatSidebar.vue');
const ChatView = async () => await import('@/features/ai/chatHub/ChatView.vue');
@ -10,7 +16,31 @@ export const ChatModule: FrontendModuleDescription = {
name: 'Chat',
description: 'Interact with various LLM models or your n8n AI agents.',
icon: 'chat',
modals: [],
modals: [
{
key: TOOLS_SELECTOR_MODAL_KEY,
component: async () => await import('./components/ToolsSelectorModal.vue'),
initialState: {
open: false,
data: {
selected: [],
onConfirm: () => {},
},
},
},
{
key: AGENT_EDITOR_MODAL_KEY,
component: async () => await import('./components/AgentEditorModal.vue'),
initialState: {
open: false,
data: {
credentials: {},
onClose: () => {},
onCreateCustomAgent: () => {},
},
},
},
],
routes: [
{
name: CHAT_VIEW,