feat(editor): show community tools

This commit is contained in:
yehorkardash 2025-11-18 15:30:06 +01:00
parent 2661162238
commit 3dc317acc0
No known key found for this signature in database
12 changed files with 117 additions and 193 deletions

View File

@ -5,10 +5,16 @@ export const validateNodeName = (name: string): string | undefined => {
const regexScoped = /^@([a-z0-9]+(?:-[a-z0-9]+)*)\/n8n-nodes-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
// 2. Matches 'n8n-nodes-anything'
const regexUnscoped = /^n8n-nodes-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
// 3. Matches 'anythingTool'
const regexTool = /^.+\wTool$/;
if (!regexScoped.test(name) && !regexUnscoped.test(name)) {
return "Must start with 'n8n-nodes-' or '@org/n8n-nodes-'. Examples: n8n-nodes-my-app, @mycompany/n8n-nodes-my-app";
}
if (regexTool.test(name)) {
return "Please remove the 'Tool' suffix from the node name. Examples: n8n-nodes-my-app, @mycompany/n8n-nodes-my-app";
}
return;
};

View File

@ -1,8 +1,9 @@
import type { CommunityNodeType } from '@n8n/api-types';
import { Logger, inProduction } from '@n8n/backend-common';
import { inProduction, Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { ensureError } from 'n8n-workflow';
import { ensureError, NodeConnectionTypes } from 'n8n-workflow';
import cloneDeep from 'lodash/cloneDeep';
import { getCommunityNodeTypes, StrapiCommunityNodeType } from './community-node-types-utils';
import { CommunityPackagesConfig } from './community-packages.config';
import { CommunityPackagesService } from './community-packages.service';
@ -58,9 +59,36 @@ export class CommunityNodeTypesService {
this.communityNodeTypes = new Map(nodeTypes.map((nodeType) => [nodeType.name, nodeType]));
this.createAiTools();
this.lastUpdateTimestamp = Date.now();
}
private createAiTools() {
const usableAsTools = Array.from(this.communityNodeTypes.values()).filter(
// TODO: remove "|| true"
(nodeType) => nodeType.nodeDescription.usableAsTool || true,
);
for (const nodeType of usableAsTools) {
const clonedNodeType = cloneDeep(nodeType);
clonedNodeType.name += 'Tool';
clonedNodeType.nodeDescription.name += 'Tool';
clonedNodeType.nodeDescription.inputs = [];
clonedNodeType.nodeDescription.outputs = [NodeConnectionTypes.AiTool];
clonedNodeType.nodeDescription.displayName += ' Tool';
clonedNodeType.nodeDescription.codex = {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
Tools: clonedNodeType.nodeDescription.codex?.subcategories?.Tools ?? ['Other Tools'],
},
resources: clonedNodeType.nodeDescription.codex?.resources ?? {},
};
this.communityNodeTypes.set(clonedNodeType.nodeDescription.name, clonedNodeType);
}
}
private resetCommunityNodeTypes() {
this.communityNodeTypes = new Map();
}

View File

@ -131,6 +131,7 @@ import { useTemplatesStore } from '@/features/workflows/templates/templates.stor
import { tryToParseNumber } from '@/app/utils/typesUtils';
import { isValidNodeConnectionType } from '@/app/utils/typeGuards';
import { useParentFolder } from '@/features/core/folders/composables/useParentFolder';
import { removePreviewToken } from '@/features/shared/nodeCreator/nodeCreator.utils';
type AddNodeData = Partial<INodeUi> & {
type: string;
@ -1020,7 +1021,7 @@ export function useCanvasOperations() {
node.name ??
nodeHelpers.getDefaultNodeName(node) ??
(nodeTypeDescription.defaults.name as string);
const type = nodeTypeDescription.name;
const type = node.type ?? nodeTypeDescription.name;
const typeVersion = node.typeVersion;
const position =
options.forcePosition && node.position
@ -1043,8 +1044,11 @@ export function useCanvasOperations() {
};
resolveNodeName(nodeData);
resolveNodeParameters(nodeData, nodeTypeDescription);
resolveNodeWebhook(nodeData, nodeTypeDescription);
if (nodeTypesStore.getIsNodeInstalled(nodeData.type)) {
resolveNodeParameters(nodeData, nodeTypeDescription);
resolveNodeWebhook(nodeData, nodeTypeDescription);
}
return nodeData;
}
@ -1741,12 +1745,10 @@ export function useCanvasOperations() {
workflowHelpers.initState(data);
data.nodes.forEach((node) => {
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
const isUnknownNode =
!nodeTypesStore.getNodeType(node.type, node.typeVersion) &&
!nodeTypesStore.communityNodeType(node.type)?.nodeDescription;
const isInstalledNode = nodeTypesStore.getIsNodeInstalled(node.type);
nodeHelpers.matchCredentials(node);
// skip this step because nodeTypeDescription is missing for unknown nodes
if (!isUnknownNode) {
if (isInstalledNode) {
resolveNodeParameters(node, nodeTypeDescription);
resolveNodeWebhook(node, nodeTypeDescription);
}
@ -1757,14 +1759,19 @@ export function useCanvasOperations() {
const initializeUnknownNodes = (nodes: INode[]) => {
nodes.forEach((node) => {
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
// we need to fetch installed node, so remove preview token
const nodeTypeDescription = requireNodeTypeDescription(
removePreviewToken(node.type),
node.typeVersion,
);
nodeHelpers.matchCredentials(node);
resolveNodeParameters(node, nodeTypeDescription);
resolveNodeWebhook(node, nodeTypeDescription);
const nodeIndex = workflowsStore.workflow.nodes.findIndex((n) => {
return n.name === node.name;
});
workflowState.updateNodeAtIndex(nodeIndex, node);
// make sure that preview node type is always removed
workflowState.updateNodeAtIndex(nodeIndex, { ...node, type: removePreviewToken(node.type) });
});
};
@ -1897,8 +1904,21 @@ export function useCanvasOperations() {
// Create a workflow with the new nodes and connections that we can use
// the rename method
const tempWorkflow: Workflow = workflowsStore.createWorkflowObject(createNodes, newConnections);
const tempWorkflow: Workflow = workflowsStore.createWorkflowObject(
createNodes,
newConnections,
true,
);
// createWorkflowObject strips out unknown parameters, bring them back for not installed nodes
for (const nodeName of Object.keys(tempWorkflow.nodes)) {
const node = tempWorkflow.nodes[nodeName];
const isInstalledNode = nodeTypesStore.getIsNodeInstalled(node.type);
if (!isInstalledNode) {
const originalParameters = createNodes.find((n) => n.name === nodeName)?.parameters;
node.parameters = originalParameters ?? node.parameters;
}
}
// Rename all the nodes of which the name changed
for (oldName in nodeNameTable) {
if (oldName === nodeNameTable[oldName]) {

View File

@ -54,7 +54,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
const communityNodeType = computed(() => {
return (nodeTypeName: string) => {
return vettedCommunityNodeTypes.value.get(nodeTypeName);
return vettedCommunityNodeTypes.value.get(removePreviewToken(nodeTypeName));
};
});
@ -146,13 +146,13 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
if (!workflow.nodes[node.name]) {
return false;
}
const nodeType = getNodeType.value(nodeTypeName);
const nodeType =
getNodeType.value(nodeTypeName) ?? communityNodeType.value(nodeTypeName)?.nodeDescription;
if (!nodeType) {
return false;
}
const outputs = NodeHelpers.getNodeOutputs(workflow, node, nodeType);
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
return outputTypes
? outputTypes.filter((output) => output !== NodeConnectionTypes.Main).length > 0
: false;
@ -338,7 +338,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
const getNodeTypes = async () => {
const nodeTypes = await nodeTypesApi.getNodeTypes(rootStore.baseUrl);
if (nodeTypes.length) {
setNodeTypes(nodeTypes);
}
@ -420,8 +419,10 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
const getIsNodeInstalled = computed(() => {
return (nodeTypeName: string) => {
const cleanedNodeTypeName = removePreviewToken(nodeTypeName);
return (
!!getNodeType.value(nodeTypeName) || !!communityNodeType.value(nodeTypeName)?.isInstalled
!!getNodeType.value(cleanedNodeTypeName) ||
!!communityNodeType.value(cleanedNodeTypeName)?.isInstalled
);
};
});

View File

@ -499,7 +499,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
nodeTypes: {},
init: async (): Promise<void> => {},
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = nodeTypesStore.getNodeType(nodeType, version);
const nodeTypeDescription =
nodeTypesStore.getNodeType(nodeType, version) ??
nodeTypesStore.communityNodeType(nodeType)?.nodeDescription ??
null;
if (nodeTypeDescription === null) {
return undefined;
}

View File

@ -128,7 +128,10 @@ const onUninstall = async () => {
});
loading.value = true;
await communityNodesStore.uninstallPackage(props.activePackageName);
await useNodeTypesStore().getNodeTypes();
await Promise.all([
useNodeTypesStore().getNodeTypes(),
useNodeTypesStore().fetchCommunityNodePreviews(),
]);
toast.showMessage({
title: i18n.baseText('settings.communityNodes.messages.uninstall.success.title'),
type: 'success',

View File

@ -68,11 +68,15 @@ export function useInstallNode() {
}
// refresh store information about installed nodes
await nodeTypesStore.getNodeTypes();
await credentialsStore.fetchCredentialTypes(true);
await Promise.all([
nodeTypesStore.getNodeTypes(),
nodeTypesStore.fetchCommunityNodePreviews(),
credentialsStore.fetchCredentialTypes(true),
]);
await nextTick();
// update parameters and webhooks for freshly installed nodes
// rename types from preview version to the actual version
const nodeType = props.nodeType;
if (nodeType && workflowsStore.workflow.nodes?.length) {
const nodesToUpdate = workflowsStore.workflow.nodes.filter(

View File

@ -148,9 +148,15 @@ function onSelected(item: INodeCreateElement) {
}
if (item.type === 'node') {
const payload = nodeCreateElementToNodeTypeSelectedPayload(item);
let nodeActions = getFilteredActions(item, actions);
const notInstalledCommunityNode =
isCommunityPackageName(item.key) && !useNodeTypesStore().getIsNodeInstalled(item.key);
if (shouldShowCommunityNodeDetails(isCommunityPackageName(item.key), activeViewStack.value)) {
if (
shouldShowCommunityNodeDetails(isCommunityPackageName(item.key), activeViewStack.value) ||
notInstalledCommunityNode
) {
if (!nodeActions.length) {
nodeActions = getFilteredActions(item, communityNodesAndActions.value.actions);
}
@ -166,8 +172,6 @@ function onSelected(item: INodeCreateElement) {
return;
}
const payload = nodeCreateElementToNodeTypeSelectedPayload(item);
// If there is only one action, use it
if (nodeActions.length === 1) {
emit('nodeTypeSelected', [payload]);

View File

@ -1,166 +0,0 @@
<script setup lang="ts">
import { useInstallNode } from '@/features/settings/communityNodes/composables/useInstallNode';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { getNodeIconSource } from '@/app/utils/nodeIcon';
import { N8nButton, N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
import { i18n } from '@n8n/i18n';
import OfficialIcon from 'virtual:icons/mdi/verified';
import { computed } from 'vue';
import { useViewStacks } from '../../composables/useViewStacks';
import { prepareCommunityNodeDetailsViewStack, removePreviewToken } from '../../nodeCreator.utils';
import NodeIcon from '@/app/components/NodeIcon.vue';
const {
activeViewStack,
pushViewStack,
popViewStack,
getAllNodeCreateElements,
updateCurrentViewStack,
} = useViewStacks();
const { communityNodeDetails } = activeViewStack;
const nodeCreatorStore = useNodeCreatorStore();
const { installNode, loading } = useInstallNode();
const isOwner = computed(() => useUsersStore().isInstanceOwner);
const updateViewStack = (key: string) => {
const installedNodeKey = removePreviewToken(key);
const installedNode = getAllNodeCreateElements().find((node) => node.key === installedNodeKey);
if (installedNode) {
const nodeActions = nodeCreatorStore.actions?.[installedNode.key] || [];
popViewStack();
updateCurrentViewStack({ searchItems: nodeCreatorStore.mergedNodes });
const viewStack = prepareCommunityNodeDetailsViewStack(
installedNode,
getNodeIconSource(installedNode.properties),
activeViewStack.rootView,
nodeActions,
);
pushViewStack(viewStack, {
transitionDirection: 'none',
});
} else {
const viewStack = { ...activeViewStack };
viewStack.communityNodeDetails!.installed = true;
pushViewStack(activeViewStack, { resetStacks: true });
}
};
const updateStoresAndViewStack = (key: string) => {
updateViewStack(key);
nodeCreatorStore.removeNodeFromMergedNodes(key);
};
const onInstall = async () => {
if (isOwner.value && activeViewStack.communityNodeDetails && !communityNodeDetails?.installed) {
const { key, packageName } = activeViewStack.communityNodeDetails;
const result = await installNode({ type: 'verified', packageName, nodeType: key });
if (result.success) {
updateStoresAndViewStack(key);
}
}
};
</script>
<template>
<div v-if="communityNodeDetails" :class="$style.container">
<div :class="$style.header">
<div :class="$style.title">
<NodeIcon
v-if="communityNodeDetails.nodeIcon"
:class="$style.nodeIcon"
:icon-source="communityNodeDetails.nodeIcon"
:circle="false"
:show-tooltip="false"
/>
<span>{{ communityNodeDetails.title }}</span>
<N8nTooltip v-if="communityNodeDetails.official" placement="bottom" :show-after="500">
<template #content>
{{
i18n.baseText('generic.officialNode.tooltip', {
interpolate: {
author: communityNodeDetails.companyName ?? communityNodeDetails.title,
},
})
}}
</template>
<OfficialIcon :class="$style.officialIcon" />
</N8nTooltip>
</div>
<div>
<div v-if="communityNodeDetails.installed" :class="$style.installed">
<N8nIcon v-if="!communityNodeDetails.official" :class="$style.installedIcon" icon="box" />
<N8nText color="text-light" size="small" bold>
{{ i18n.baseText('communityNodeDetails.installed') }}
</N8nText>
</div>
<N8nButton
v-if="isOwner && !communityNodeDetails.installed"
:loading="loading"
:disabled="loading"
:label="i18n.baseText('communityNodeDetails.install')"
size="small"
data-test-id="install-community-node-button"
@click="onInstall"
/>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.container {
width: 100%;
padding: var(--spacing--sm);
display: flex;
flex-direction: column;
padding-bottom: var(--spacing--xs);
}
.header {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
justify-content: space-between;
}
.title {
display: flex;
align-items: center;
color: var(--color--text);
font-size: var(--font-size--xl);
font-weight: var(--font-weight--bold);
}
.nodeIcon {
--node--icon--size: 36px;
margin-right: var(--spacing--sm);
}
.installedIcon {
margin-right: var(--spacing--3xs);
color: var(--color--text);
font-size: var(--font-size--2xs);
}
.officialIcon {
display: inline-flex;
flex-shrink: 0;
margin-left: var(--spacing--4xs);
color: var(--color--text);
width: 14px;
}
.installed {
display: flex;
align-items: center;
margin-right: var(--spacing--xs);
}
</style>

View File

@ -42,7 +42,11 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { sortNodeCreateElements, transformNodeType } from '../nodeCreator.utils';
import {
removePreviewToken,
sortNodeCreateElements,
transformNodeType,
} from '../nodeCreator.utils';
import { useI18n } from '@n8n/i18n';
import { PUSH_NODES_OFFSET } from '@/app/utils/nodeViewUtils';
import { useCanvasStore } from '@/app/stores/canvas.store';
@ -215,7 +219,7 @@ export const useActions = () => {
actionData: NodeCreateElement,
): NodeTypeSelectedPayload {
const result: NodeTypeSelectedPayload = {
type: actionData.key,
type: removePreviewToken(actionData.key),
};
if (typeof actionData.resource === 'string' || typeof actionData.operation === 'string') {

View File

@ -1042,6 +1042,10 @@ export function getNodeOutputs(
): Array<NodeConnectionType | INodeOutputConfiguration> {
let outputs: Array<NodeConnectionType | INodeOutputConfiguration> = [];
if (!nodeTypeData) {
return [];
}
if (Array.isArray(nodeTypeData.outputs)) {
outputs = nodeTypeData.outputs;
} else {
@ -1567,6 +1571,9 @@ export function isTriggerNode(nodeTypeData: INodeTypeDescription) {
}
export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INodeTypeDescription) {
if (!nodeTypeData) {
return false;
}
const outputs = getNodeOutputs(workflow, node, nodeTypeData);
const outputNames = getConnectionTypes(outputs);
return (

View File

@ -26,6 +26,7 @@ import type {
WorkflowExecuteMode,
ProxyInput,
INode,
INodeType,
} from './interfaces';
import * as NodeHelpers from './node-helpers';
import { createResultError, createResultOk } from './result';
@ -165,7 +166,16 @@ export class WorkflowDataProxy {
}
private buildAgentToolInfo(node: INode) {
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
const nodeType: INodeType | undefined = this.workflow.nodeTypes.getByNameAndVersion(
node.type,
node.typeVersion,
);
if (!nodeType) {
return {
name: node.name,
type: node.type,
};
}
const type = nodeType.description.displayName;
const params = NodeHelpers.getNodeParameters(
nodeType.description.properties,