feat(editor): Add pre-built agents experiment (#18124)

This commit is contained in:
Jaakko Husso
2025-08-09 10:28:14 +03:00
committed by GitHub
parent a3b625fc18
commit 5a69d2a2f3
44 changed files with 2635 additions and 653 deletions

View File

@@ -9,15 +9,15 @@ defineProps<Props>();
</script>
<template>
<n8n-node-creator-node
:class="$style.creatorOpenTemplate"
<N8nNodeCreatorNode
:class="{ [$style.creatorOpenTemplate]: true, [$style.compact]: openTemplate.compact }"
:title="openTemplate.title"
:is-trigger="false"
:description="openTemplate.description"
:tag="openTemplate.tag"
:show-action-arrow="true"
:is-trigger="false"
>
<template #icon>
<template v-if="openTemplate.icon" #icon>
<n8n-node-icon
type="icon"
:name="openTemplate.icon"
@@ -26,7 +26,17 @@ defineProps<Props>();
:use-updated-icons="true"
/>
</template>
</n8n-node-creator-node>
<template v-if="openTemplate.nodes" #extraDetails>
<NodeIcon
v-for="node in openTemplate.nodes"
:key="node.name"
:node-type="node"
:size="16"
:show-tooltip="true"
/>
</template>
</N8nNodeCreatorNode>
</template>
<style lang="scss" module>
@@ -34,5 +44,11 @@ defineProps<Props>();
--action-arrow-color: var(--color-text-light);
margin-left: var(--spacing-s);
margin-right: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
}
.compact {
margin-left: 0;
padding-right: 0;
}
</style>

View File

@@ -29,10 +29,11 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@n8n/i18n';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import OrderSwitcher from './../OrderSwitcher.vue';
import { isNodePreviewKey } from '../utils';
import { getActiveViewCallouts, isNodePreviewKey } from '../utils';
import CommunityNodeInfo from '../Panel/CommunityNodeInfo.vue';
import CommunityNodeFooter from '../Panel/CommunityNodeFooter.vue';
import { useCalloutHelpers } from '@/composables/useCalloutHelpers';
const emit = defineEmits<{
nodeTypeSelected: [value: NodeTypeSelectedPayload[]];
@@ -53,6 +54,7 @@ const {
} = useActions();
const nodeCreatorStore = useNodeCreatorStore();
const calloutHelpers = useCalloutHelpers();
// We only inject labels if search is empty
const parsedTriggerActions = computed(() =>
@@ -159,6 +161,15 @@ function onKeySelect(activeItemId: string) {
}
function onSelected(actionCreateElement: INodeCreateElement) {
if (actionCreateElement.type === 'openTemplate') {
calloutHelpers.openSampleWorkflowTemplate(actionCreateElement.properties.templateId, {
telemetry: {
source: 'nodeCreator',
section: useViewStacks().activeViewStack.title,
},
});
}
if (actionCreateElement.type !== 'action') return;
const actionData = getActionData(actionCreateElement.properties);
@@ -234,6 +245,14 @@ function addHttpNode() {
onMounted(() => {
trackActionsView();
});
const callouts = computed<INodeCreateElement[]>(() =>
getActiveViewCallouts(
useViewStacks().activeViewStack.title,
calloutHelpers.isPreBuiltAgentsCalloutVisible.value,
calloutHelpers.getPreBuiltAgentNodeCreatorItems(),
),
);
</script>
<template>
@@ -243,6 +262,8 @@ onMounted(() => {
[$style.containerPaddingBottom]: !communityNodeDetails,
}"
>
<ItemsRenderer :elements="callouts" :class="$style.items" @selected="onSelected" />
<CommunityNodeInfo v-if="communityNodeDetails" />
<OrderSwitcher v-if="rootView" :root-view="rootView">
<template v-if="shouldShowTriggers" #triggers>

View File

@@ -16,6 +16,7 @@ import {
AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
HITL_SUBCATEGORY,
PRE_BUILT_AGENTS_COLLECTION,
} from '@/constants';
import type { BaseTextKey } from '@n8n/i18n';
@@ -28,6 +29,7 @@ import {
prepareCommunityNodeDetailsViewStack,
transformNodeType,
getRootSearchCallouts,
getActiveViewCallouts,
shouldShowCommunityNodeDetails,
getHumanInTheLoopActions,
} from '../utils';
@@ -104,6 +106,17 @@ function getFilteredActions(
}
function onSelected(item: INodeCreateElement) {
if (item.key === PRE_BUILT_AGENTS_COLLECTION) {
void calloutHelpers.openPreBuiltAgentsCollection({
telemetry: {
source: 'nodeCreator',
section: activeViewStack.value.title,
},
resetStacks: false,
});
return;
}
if (item.type === 'subcategory') {
const subcategoryKey = camelCase(item.properties.title);
const title = i18n.baseText(`nodeCreator.subcategoryNames.${subcategoryKey}` as BaseTextKey);
@@ -223,9 +236,12 @@ function onSelected(item: INodeCreateElement) {
}
if (item.type === 'openTemplate') {
if (item.properties.key === 'rag-starter-template') {
void calloutHelpers.openRagStarterTemplate();
}
calloutHelpers.openSampleWorkflowTemplate(item.properties.templateId, {
telemetry: {
source: 'nodeCreator',
section: activeViewStack.value.title,
},
});
}
}
@@ -265,11 +281,16 @@ function baseSubcategoriesFilter(item: INodeCreateElement): boolean {
return hasActions || !hasTriggerGroup;
}
const globalCallouts = computed<INodeCreateElement[]>(() =>
getRootSearchCallouts(activeViewStack.value.search ?? '', {
const globalCallouts = computed<INodeCreateElement[]>(() => [
...getRootSearchCallouts(activeViewStack.value.search ?? '', {
isRagStarterCalloutVisible: calloutHelpers.isRagStarterCalloutVisible.value,
}),
);
...getActiveViewCallouts(
activeViewStack.value.title,
calloutHelpers.isPreBuiltAgentsCalloutVisible.value,
calloutHelpers.getPreBuiltAgentNodeCreatorItems(),
),
]);
function arrowLeft() {
popViewStack();

View File

@@ -305,6 +305,8 @@ watch(
&:last-child {
margin-top: 0;
padding-top: 0;
margin-bottom: 0;
padding-bottom: 0;
&:after {
content: none;

View File

@@ -8,7 +8,7 @@ exports[`viewsData > AIView > should return ai view with ai transform node 1`] =
"properties": {
"description": "See what's possible and get started 5x faster",
"icon": "box-open",
"name": "ai_templates_root",
"key": "ai_templates_root",
"tag": {
"text": "Recommended",
"type": "info",
@@ -81,7 +81,7 @@ exports[`viewsData > AIView > should return ai view without ai transform node if
"properties": {
"description": "See what's possible and get started 5x faster",
"icon": "box-open",
"name": "ai_templates_root",
"key": "ai_templates_root",
"tag": {
"text": "Recommended",
"type": "info",
@@ -141,7 +141,7 @@ exports[`viewsData > AIView > should return ai view without ai transform node if
"properties": {
"description": "See what's possible and get started 5x faster",
"icon": "box-open",
"name": "ai_templates_root",
"key": "ai_templates_root",
"tag": {
"text": "Recommended",
"type": "info",

View File

@@ -7,17 +7,24 @@ import type {
SectionCreateElement,
ActionTypeDescription,
NodeFilterType,
OpenTemplateElement,
LinkCreateElement,
ViewCreateElement,
} from '@/Interface';
import {
AI_CATEGORY_AGENTS,
AI_CATEGORY_OTHER_TOOLS,
AI_CATEGORY_TOOLS,
AI_SUBCATEGORY,
AI_TRANSFORM_NODE_TYPE,
AI_CATEGORY_LANGUAGE_MODELS,
AI_CATEGORY_MEMORY,
CORE_NODES_CATEGORY,
DEFAULT_SUBCATEGORY,
DISCORD_NODE_TYPE,
HUMAN_IN_THE_LOOP_CATEGORY,
MICROSOFT_TEAMS_NODE_TYPE,
PRE_BUILT_AGENTS_COLLECTION,
} from '@/constants';
import { v4 as uuidv4 } from 'uuid';
@@ -28,10 +35,11 @@ import sortBy from 'lodash/sortBy';
import * as changeCase from 'change-case';
import { useSettingsStore } from '@/stores/settings.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { NodeIconSource } from '../../../utils/nodeIcon';
import type { CommunityNodeDetails, ViewStack } from './composables/useViewStacks';
import { useNodeTypesStore } from '../../../stores/nodeTypes.store';
import { PrebuiltAgentTemplates, SampleTemplates } from '@/utils/templates/workflowSamples';
const COMMUNITY_NODE_TYPE_PREVIEW_TOKEN = '-preview';
@@ -308,30 +316,140 @@ export function prepareCommunityNodeDetailsViewStack(
};
}
export function getRootSearchCallouts(search: string, { isRagStarterCalloutVisible = false }) {
export function getRagStarterCallout(): OpenTemplateElement {
return {
uuid: SampleTemplates.RagStarterTemplate,
key: SampleTemplates.RagStarterTemplate,
type: 'openTemplate',
properties: {
templateId: SampleTemplates.RagStarterTemplate,
title: i18n.baseText('nodeCreator.ragStarterTemplate.openTemplateItem.title'),
icon: 'database',
description: i18n.baseText('nodeCreator.ragStarterTemplate.openTemplateItem.description'),
tag: {
type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),
},
},
};
}
// Callout without a divider
export function getPreBuiltAgentsCallout(): ViewCreateElement {
return {
key: PRE_BUILT_AGENTS_COLLECTION,
type: 'view',
properties: {
title: i18n.baseText('nodeCreator.preBuiltAgents.title'),
icon: 'box',
description: i18n.baseText('nodeCreator.preBuiltAgents.description'),
borderless: true,
tag: {
type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),
},
},
};
}
// Callout with divider after it
export function getPreBuiltAgentsCalloutWithDivider(): LinkCreateElement {
return {
key: PRE_BUILT_AGENTS_COLLECTION,
type: 'link',
properties: {
key: PRE_BUILT_AGENTS_COLLECTION,
url: '',
title: i18n.baseText('nodeCreator.preBuiltAgents.title'),
icon: 'box',
description: i18n.baseText('nodeCreator.preBuiltAgents.description'),
tag: {
type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),
},
},
};
}
export function getAiTemplatesCallout(aiTemplatesURL: string): LinkCreateElement {
return {
key: 'ai_templates_root',
type: 'link',
properties: {
title: i18n.baseText('nodeCreator.aiPanel.linkItem.title'),
icon: 'box-open',
description: i18n.baseText('nodeCreator.aiPanel.linkItem.description'),
key: 'ai_templates_root',
url: aiTemplatesURL,
tag: {
type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),
},
},
};
}
export function getRootSearchCallouts(search: string, { isRagStarterCalloutVisible = false } = {}) {
const results: INodeCreateElement[] = [];
const ragKeywords = ['rag', 'vec', 'know'];
if (isRagStarterCalloutVisible && ragKeywords.some((x) => search.toLowerCase().startsWith(x))) {
results.push({
uuid: 'rag-starter-template',
key: 'rag-starter-template',
type: 'openTemplate',
properties: {
key: 'rag-starter-template',
title: i18n.baseText('nodeCreator.ragStarterTemplate.openTemplateItem.title'),
icon: 'database',
description: i18n.baseText('nodeCreator.ragStarterTemplate.openTemplateItem.description'),
tag: {
type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),
},
},
});
results.push(getRagStarterCallout());
}
return results;
}
const getTemplateLink = (
templateId: string,
availableTemplates: OpenTemplateElement[],
): OpenTemplateElement | undefined => {
const templateLink = availableTemplates.find((template) => template.key === templateId);
if (templateLink?.properties) {
templateLink.properties.compact = true;
}
return templateLink;
};
export function getActiveViewCallouts(
title: string | undefined,
isPreBuiltAgentsCalloutVisible: boolean,
templates: OpenTemplateElement[],
) {
const results: INodeCreateElement[] = [];
if (isPreBuiltAgentsCalloutVisible && title) {
if (title === AI_CATEGORY_LANGUAGE_MODELS) {
results.push(getPreBuiltAgentsCalloutWithDivider());
} else if ([AI_CATEGORY_MEMORY, AI_CATEGORY_TOOLS].includes(title)) {
results.push(getPreBuiltAgentsCallout());
} else if (title === 'Google Calendar' || title === 'Telegram') {
const templateLink = getTemplateLink(PrebuiltAgentTemplates.VoiceAssistantAgent, templates);
if (templateLink) {
results.push(templateLink);
}
} else if (title === 'Google Drive') {
const templateLink = getTemplateLink(PrebuiltAgentTemplates.KnowledgeStoreAgent, templates);
if (templateLink) {
results.push(templateLink);
}
} else if (title === 'Google Sheets') {
const templateLink = getTemplateLink(PrebuiltAgentTemplates.TaskManagementAgent, templates);
if (templateLink) {
results.push(templateLink);
}
} else if (title === 'Gmail') {
const templateLink = getTemplateLink(PrebuiltAgentTemplates.EmailTriageAgent, templates);
if (templateLink) {
results.push(templateLink);
}
}
}
return results;
}
export const shouldShowCommunityNodeDetails = (communityNode: boolean, viewStack: ViewStack) => {
if (viewStack.rootView === 'AI Other' && viewStack.title === 'Tools') {
return false;

View File

@@ -1,6 +1,11 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { AI_CATEGORY_AGENTS, AI_CATEGORY_CHAINS, AI_TRANSFORM_NODE_TYPE } from '@/constants';
import {
AI_CATEGORY_AGENTS,
AI_CATEGORY_CHAINS,
AI_TRANSFORM_NODE_TYPE,
PRE_BUILT_AGENTS_EXPERIMENT,
} from '@/constants';
import type { INodeTypeDescription } from 'n8n-workflow';
import { START_NODE_TYPE } from 'n8n-workflow';
import { useSettingsStore } from '@/stores/settings.store';
@@ -55,7 +60,14 @@ describe('viewsData', () => {
setActivePinia(createTestingPinia());
posthogStore = usePostHog();
vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(true);
vi.spyOn(posthogStore, 'isVariantEnabled').mockImplementation((experiment) => {
if (experiment === PRE_BUILT_AGENTS_EXPERIMENT.name) {
return false;
}
return true;
});
const templatesStore = useTemplatesStore();

View File

@@ -69,6 +69,9 @@ import type { BaseTextKey } from '@n8n/i18n';
import camelCase from 'lodash/camelCase';
import { useSettingsStore } from '@/stores/settings.store';
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
import { getAiTemplatesCallout, getPreBuiltAgentsCalloutWithDivider } from './utils';
import { useCalloutHelpers } from '@/composables/useCalloutHelpers';
export interface NodeViewItemSection {
key: string;
title: string;
@@ -79,6 +82,7 @@ export interface NodeViewItem {
key: string;
type: string;
properties: {
key?: string;
name?: string;
title?: string;
icon?: Themed<string>;
@@ -94,7 +98,7 @@ export interface NodeViewItem {
description?: string;
displayName?: string;
tag?: {
type: string;
type?: string;
text: string;
};
forceIncludeNodes?: string[];
@@ -168,6 +172,7 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
const nodeTypesStore = useNodeTypesStore();
const templatesStore = useTemplatesStore();
const evaluationStore = useEvaluationStore();
const calloutHelpers = useCalloutHelpers();
const isEvaluationEnabled = evaluationStore.isEvaluationEnabled;
const evaluationNode = getEvaluationNode(nodeTypesStore, isEvaluationEnabled);
@@ -186,26 +191,16 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
const aiTransformNode = nodeTypesStore.getNodeType(AI_TRANSFORM_NODE_TYPE);
const transformNode = askAiEnabled && aiTransformNode ? [getNodeView(aiTransformNode)] : [];
const callouts: NodeViewItem[] = !calloutHelpers.isPreBuiltAgentsCalloutVisible.value
? [getAiTemplatesCallout(aiTemplatesURL)]
: [getPreBuiltAgentsCalloutWithDivider()];
return {
value: AI_NODE_CREATOR_VIEW,
title: i18n.baseText('nodeCreator.aiPanel.aiNodes'),
subtitle: i18n.baseText('nodeCreator.aiPanel.selectAiNode'),
items: [
{
key: 'ai_templates_root',
type: 'link',
properties: {
title: i18n.baseText('nodeCreator.aiPanel.linkItem.title'),
icon: 'box-open',
description: i18n.baseText('nodeCreator.aiPanel.linkItem.description'),
name: 'ai_templates_root',
url: aiTemplatesURL,
tag: {
type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),
},
},
},
...callouts,
...agentNodes,
...chainNodes,
...transformNode,

View File

@@ -55,7 +55,8 @@ export const TEST_PARAMETERS: INodeProperties[] = [
typeOptions: {
calloutAction: {
label: 'and action!',
type: 'openRagStarterTemplate',
type: 'openSampleWorkflowTemplate',
templateId: 'test-template-id',
},
},
},

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type {
CalloutActionType,
CalloutAction,
INodeParameters,
INodeProperties,
NodeParameterValueType,
@@ -47,6 +47,7 @@ import { storeToRefs } from 'pinia';
import { useCalloutHelpers } from '@/composables/useCalloutHelpers';
import { getParameterTypeOption } from '@/utils/nodeSettingsUtils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
const LazyFixedCollectionParameter = defineAsyncComponent(
async () => await import('./FixedCollectionParameter.vue'),
@@ -86,8 +87,14 @@ const nodeSettingsParameters = useNodeSettingsParameters();
const asyncLoadingError = ref(false);
const workflowHelpers = useWorkflowHelpers();
const i18n = useI18n();
const { dismissCallout, isCalloutDismissed, openRagStarterTemplate, isRagStarterCalloutVisible } =
useCalloutHelpers();
const {
dismissCallout,
isCalloutDismissed,
openPreBuiltAgentsCollection,
openSampleWorkflowTemplate,
isRagStarterCalloutVisible,
isPreBuiltAgentsCalloutVisible,
} = useCalloutHelpers();
const { activeNode } = storeToRefs(ndvStore);
@@ -154,12 +161,22 @@ const credentialsParameterIndex = computed(() => {
return filteredParameters.value.findIndex((parameter) => parameter.type === 'credentials');
});
const calloutParameterIndex = computed(() => {
return filteredParameters.value.findIndex((parameter) => parameter.type === 'callout');
});
const indexToShowSlotAt = computed(() => {
if (credentialsParameterIndex.value !== -1) {
return credentialsParameterIndex.value;
}
let index = 0;
// If the node has a callout don't show credentials before it
if (calloutParameterIndex.value !== -1) {
index = calloutParameterIndex.value + 1;
}
// For nodes that use old credentials UI, keep credentials below authentication field in NDV
// otherwise credentials will use auth filed position since the auth field is moved to credentials modal
const fieldOffset = KEEP_AUTH_IN_NDV_FOR_NODES.includes(nodeType.value?.name || '') ? 1 : 0;
@@ -405,6 +422,14 @@ function isRagStarterCallout(parameter: INodeProperties): boolean {
return parameter.type === 'callout' && parameter.name === 'ragStarterCallout';
}
function isAgentDefaultCallout(parameter: INodeProperties): boolean {
return parameter.type === 'callout' && parameter.name === 'aiAgentStarterCallout';
}
function isPreBuiltAgentsCallout(parameter: INodeProperties): boolean {
return parameter.type === 'callout' && parameter.name.startsWith('preBuiltAgentsCallout');
}
function isCalloutVisible(parameter: INodeProperties): boolean {
if (isCalloutDismissed(parameter.name)) return false;
@@ -412,12 +437,38 @@ function isCalloutVisible(parameter: INodeProperties): boolean {
return isRagStarterCalloutVisible.value;
}
if (isAgentDefaultCallout(parameter)) {
return !isPreBuiltAgentsCalloutVisible.value;
}
if (isPreBuiltAgentsCallout(parameter)) {
return isPreBuiltAgentsCalloutVisible.value;
}
return true;
}
function onCalloutAction(action: CalloutActionType) {
if (action === 'openRagStarterTemplate') {
openRagStarterTemplate(activeNode.value?.type ?? 'no active node');
function onCalloutAction(action: CalloutAction) {
switch (action.type) {
case 'openPreBuiltAgentsCollection':
void openPreBuiltAgentsCollection({
telemetry: {
source: 'ndv',
nodeType: activeNode.value?.type,
},
resetStacks: false,
});
break;
case 'openSampleWorkflowTemplate':
void openSampleWorkflowTemplate(action.templateId, {
telemetry: {
source: 'ndv',
nodeType: activeNode.value?.type,
},
});
break;
default:
break;
}
}
@@ -483,6 +534,8 @@ async function onCalloutDismiss(parameter: INodeProperties) {
<template v-else-if="parameter.type === 'callout'">
<N8nCallout
v-if="isCalloutVisible(parameter)"
:icon="(parameter.typeOptions?.calloutAction?.icon as IconName) || 'info'"
icon-size="large"
:class="['parameter-item', parameter.typeOptions?.containerClass ?? '']"
theme="secondary"
>
@@ -499,7 +552,7 @@ async function onCalloutDismiss(parameter: INodeProperties) {
size="small"
:bold="true"
:underline="true"
@click="onCalloutAction(parameter.typeOptions.calloutAction.type)"
@click="onCalloutAction(parameter.typeOptions.calloutAction)"
>
{{ parameter.typeOptions.calloutAction.label }}
</N8nLink>