mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 17:27:14 +02:00
feat(core): Support attaching tools to custom builder agents (no-changelog) (#21550)
This commit is contained in:
parent
8e5e5965b1
commit
08a2ceaddf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user