From d0a313aa1cdafdc49afdf5fbb036209cf92c4a3b Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Fri, 13 Jun 2025 17:45:30 +0300 Subject: [PATCH] feat(editor): Add RAG starter template callouts experiment (#16282) Co-authored-by: Charlie Kolb --- cypress/composables/nodeCreator.ts | 31 +++ cypress/e2e/52-rag-callout-experiment.cy.ts | 72 +++++ .../dto/user/settings-update-request.dto.ts | 1 + .../nodes/agents/Agent/V1/AgentV1.node.ts | 4 +- .../nodes/agents/Agent/V2/AgentV2.node.ts | 6 +- .../createVectorStoreNode.test.ts.snap | 12 + .../createVectorStoreNode.ts | 15 +- .../src/components/N8nCallout/Callout.vue | 17 +- .../frontend/@n8n/i18n/src/locales/en.json | 5 + packages/frontend/editor-ui/src/Interface.ts | 17 +- .../ItemTypes/OpenTemplateItem.vue | 32 +++ .../Node/NodeCreator/Modes/NodesMode.vue | 24 +- .../Node/NodeCreator/NodesListPanel.test.ts | 6 + .../NodeCreator/Renderers/ItemsRenderer.vue | 7 + .../src/components/Node/NodeCreator/utils.ts | 23 ++ .../ParameterInputList.test.constants.ts | 26 ++ .../src/components/ParameterInputList.test.ts | 34 ++- .../src/components/ParameterInputList.vue | 107 +++++++- .../src/composables/useCalloutHelpers.test.ts | 161 +++++++++++ .../src/composables/useCalloutHelpers.ts | 81 ++++++ packages/frontend/editor-ui/src/constants.ts | 7 + .../editor-ui/src/stores/users.store.test.ts | 67 +++++ .../editor-ui/src/stores/users.store.ts | 15 + .../src/utils/easyAiWorkflowUtils.ts | 256 +++++++++++++++++- .../frontend/editor-ui/src/views/NodeView.vue | 19 +- packages/workflow/src/interfaces.ts | 9 + .../from_ai_multiple_items_workflow.json | 2 +- 27 files changed, 1032 insertions(+), 24 deletions(-) create mode 100644 cypress/composables/nodeCreator.ts create mode 100644 cypress/e2e/52-rag-callout-experiment.cy.ts create mode 100644 packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/OpenTemplateItem.vue create mode 100644 packages/frontend/editor-ui/src/composables/useCalloutHelpers.test.ts create mode 100644 packages/frontend/editor-ui/src/composables/useCalloutHelpers.ts diff --git a/cypress/composables/nodeCreator.ts b/cypress/composables/nodeCreator.ts new file mode 100644 index 0000000000..e639a13d64 --- /dev/null +++ b/cypress/composables/nodeCreator.ts @@ -0,0 +1,31 @@ +// Getters +export const nodeCreatorPlusButton = () => cy.getByTestId('node-creator-plus-button'); +export const canvasAddButton = () => cy.getByTestId('canvas-add-button'); +export const searchBar = () => cy.getByTestId('search-bar'); +export const getCategoryItem = (label: string) => cy.get(`[data-keyboard-nav-id="${label}"]`); +export const getCreatorItem = (label: string) => + getCreatorItems().contains(label).parents('[data-test-id="item-iterator-item"]'); +export const getNthCreatorItem = (n: number) => getCreatorItems().eq(n); +export const nodeCreator = () => cy.getByTestId('node-creator'); +export const nodeCreatorTabs = () => cy.getByTestId('node-creator-type-selector'); +export const selectedTab = () => nodeCreatorTabs().find('.is-active'); +export const categorizedItems = () => cy.getByTestId('categorized-items'); +export const getCreatorItems = () => cy.getByTestId('item-iterator-item'); +export const categoryItem = () => cy.getByTestId('node-creator-category-item'); +export const communityNodeTooltip = () => cy.getByTestId('node-item-community-tooltip'); +export const noResults = () => cy.getByTestId('node-creator-no-results'); +export const nodeItemName = () => cy.getByTestId('node-creator-item-name'); +export const nodeItemDescription = () => cy.getByTestId('node-creator-item-description'); +export const activeSubcategory = () => cy.getByTestId('nodes-list-header'); +export const expandedCategories = () => + getCreatorItems().find('>div').filter('.active').invoke('text'); + +// Actions +export const openNodeCreator = () => { + nodeCreatorPlusButton().click(); + nodeCreator().should('be.visible'); +}; + +export const selectNode = (displayName: string) => { + getCreatorItem(displayName).click(); +}; diff --git a/cypress/e2e/52-rag-callout-experiment.cy.ts b/cypress/e2e/52-rag-callout-experiment.cy.ts new file mode 100644 index 0000000000..ef3c58bd6d --- /dev/null +++ b/cypress/e2e/52-rag-callout-experiment.cy.ts @@ -0,0 +1,72 @@ +import { overrideFeatureFlag } from '../composables/featureFlags'; +import { openNodeCreator, searchBar } from '../composables/nodeCreator'; +import { addNodeToCanvas, navigateToNewWorkflowPage } from '../composables/workflow'; + +describe('RAG callout experiment', () => { + describe('NDV callout', () => { + it('should not show callout if experiment is control', () => { + overrideFeatureFlag('033_rag_template', 'control'); + + navigateToNewWorkflowPage(); + + addNodeToCanvas('Zep Vector Store', true, true, 'Add documents to vector store'); + + cy.contains('Tip: Get a feel for vector stores in n8n with our').should('not.exist'); + }); + + it('should callout is variant and open on click', () => { + cy.intercept('workflows/templates/rag-starter-template?fromJson=true'); + overrideFeatureFlag('033_rag_template', 'variant'); + + navigateToNewWorkflowPage(); + + addNodeToCanvas('Zep Vector Store', true, true, 'Add documents to vector store'); + + cy.contains('Tip: Get a feel for vector stores in n8n with our').should('exist'); + + let openedUrl = ''; + cy.window().then((win) => { + cy.stub(win, 'open').callsFake((url) => { + openedUrl = url; + }); + }); + cy.contains('RAG starter template').click(); + cy.then(() => cy.visit(openedUrl)); + + cy.url().should('include', '/workflows/templates/rag-starter-template?fromJson=true'); + }); + }); + describe('search callout', () => { + it('should not show callout if experiment is control', () => { + overrideFeatureFlag('033_rag_template', 'control'); + + navigateToNewWorkflowPage(); + + openNodeCreator(); + searchBar().type('rag'); + + cy.contains('RAG starter template').should('not.exist'); + }); + + it('should should callout is variant and open on click', () => { + cy.intercept('workflows/templates/rag-starter-template?fromJson=true'); + overrideFeatureFlag('033_rag_template', 'variant'); + + navigateToNewWorkflowPage(); + + openNodeCreator(); + searchBar().type('rag'); + + let openedUrl = ''; + cy.window().then((win) => { + cy.stub(win, 'open').callsFake((url) => { + openedUrl = url; + }); + }); + cy.contains('RAG starter template').should('exist').click(); + cy.then(() => cy.visit(openedUrl)); + + cy.url().should('include', '/workflows/templates/rag-starter-template?fromJson=true'); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts b/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts index 247b830d91..8c9bfa8f13 100644 --- a/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts @@ -5,4 +5,5 @@ export class SettingsUpdateRequestDto extends Z.class({ userActivated: z.boolean().optional(), allowSSOManualLogin: z.boolean().optional(), easyAIWorkflowOnboarded: z.boolean().optional(), + dismissedCallouts: z.record(z.string(), z.boolean()).optional(), }) {} diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts index 669e36b109..d4cb66b2fe 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts @@ -303,8 +303,8 @@ export class AgentV1 implements INodeType { { displayName: 'Tip: Get a feel for agents with our quick tutorial or see an example of how this node works', - name: 'notice_tip', - type: 'notice', + name: 'aiAgentStarterCallout', + type: 'callout', default: '', displayOptions: { show: { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts index fab7be971b..66adb25da1 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts @@ -119,9 +119,9 @@ export class AgentV2 implements INodeType { properties: [ { displayName: - 'Tip: Get a feel for agents with our quick tutorial or see an example of how this node works', - name: 'notice_tip', - type: 'notice', + 'Tip: Get a feel for agents with our quick tutorial or see an example of how this node works', + name: 'aiAgentStarterCallout', + type: 'callout', default: '', }, promptTypeOptions, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap index 7ab56553a3..183c0c1045 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap @@ -78,6 +78,18 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = })($parameter) }}", "properties": [ + { + "default": "", + "displayName": "Tip: Get a feel for vector stores in n8n with our", + "name": "ragStarterCallout", + "type": "callout", + "typeOptions": { + "calloutAction": { + "label": "RAG starter template", + "type": "openRagStarterTemplate", + }, + }, + }, { "default": "retrieve", "displayName": "Operation Mode", diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts index 4761d8cb8b..c0a726eed2 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts @@ -10,6 +10,7 @@ import type { SupplyData, ISupplyDataFunctions, INodeType, + INodeProperties, } from 'n8n-workflow'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; @@ -26,7 +27,18 @@ import type { NodeOperationMode, VectorStoreNodeConstructorArgs } from './types' // Import utility functions import { transformDescriptionForOperationMode, getOperationModeOptions } from './utils'; -// Import operation handlers +const ragStarterCallout: INodeProperties = { + displayName: 'Tip: Get a feel for vector stores in n8n with our', + name: 'ragStarterCallout', + type: 'callout', + typeOptions: { + calloutAction: { + label: 'RAG starter template', + type: 'openRagStarterTemplate', + }, + }, + default: '', +}; /** * Creates a vector store node with the given configuration @@ -105,6 +117,7 @@ export const createVectorStoreNode = ( })($parameter) }}`, properties: [ + ragStarterCallout, { displayName: 'Operation Mode', name: 'mode', diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCallout/Callout.vue b/packages/frontend/@n8n/design-system/src/components/N8nCallout/Callout.vue index 095149320c..a91b5d7b21 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCallout/Callout.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCallout/Callout.vue @@ -82,15 +82,26 @@ const getIconSize = computed(() => { padding: var(--spacing-xs); border: var(--border-width-base) var(--border-style-base); align-items: center; - line-height: var(--font-line-height-loose); + line-height: var(--font-line-height-xloose); border-color: var(--color-callout-info-border); background-color: var(--color-callout-info-background); color: var(--color-callout-info-font); &.slim { - line-height: var(--font-line-height-loose); + line-height: var(--font-line-height-xloose); padding: var(--spacing-3xs) var(--spacing-2xs); } + + a { + color: var(--color-secondary-link); + font-weight: var(--font-weight-medium); + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-skip-ink: none; + text-decoration-thickness: auto; + text-underline-offset: auto; + text-underline-position: from-font; + } } .round { @@ -151,7 +162,7 @@ const getIconSize = computed(() => { .icon { line-height: 1; - margin-right: var(--spacing-2xs); + margin-right: var(--spacing-xs); } .secondary { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 8281de3bdc..18a556c5e3 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1253,6 +1253,8 @@ "nodeCreator.noResults.wantUsToMakeItFaster": "Want us to make it faster?", "nodeCreator.noResults.weDidntMakeThatYet": "We didn't make that... yet", "nodeCreator.noResults.webhook": "Webhook", + "nodeCreator.ragStarterTemplate.openTemplateItem.title": "RAG starter template", + "nodeCreator.ragStarterTemplate.openTemplateItem.description": "Get a feel for vector stores in n8n", "nodeCreator.searchBar.searchNodes": "Search nodes...", "nodeCreator.subcategoryDescriptions.appTriggerNodes": "Runs the flow when something happens in an app like Telegram, Notion or Airtable", "nodeCreator.subcategoryDescriptions.appRegularNodes": "Do something in an app or service like Google Sheets, Telegram or Notion", @@ -1620,6 +1622,9 @@ "parameterInputList.parameterOptions": "Parameter Options", "parameterInputList.loadingFields": "Loading fields...", "parameterInputList.loadingError": "Error loading fields. Refresh you page and try again.", + "parameterInputList.callout.dismiss.confirm.text": "Do you want to permanently hide this?", + "parameterInputList.callout.dismiss.confirm.confirmButtonText": "Confirm", + "parameterInputList.callout.dismiss.confirm.cancelButtonText": "Cancel", "parameterOverride.overridePanelText": "Defined automatically by the model", "parameterOverride.applyOverrideButtonTooltip": "Let the model define this parameter", "parameterOverride.descriptionTooltip": "Explain to the LLM how it should generate this value, a good, specific description would allow LLMs to produce expected results much more often", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 32f7703672..01619cb19c 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -797,6 +797,15 @@ export interface LinkItemProps { icon: string; tag?: NodeCreatorTag; } + +export interface OpenTemplateItemProps { + key: 'rag-starter-template'; + title: string; + description: string; + icon: string; + tag?: NodeCreatorTag; +} + export interface ActionTypeDescription extends SimplifiedNodeType { displayOptions?: IDisplayOptions; values?: IDataObject; @@ -859,6 +868,11 @@ export interface LinkCreateElement extends CreateElementBase { properties: LinkItemProps; } +export interface OpenTemplateElement extends CreateElementBase { + type: 'openTemplate'; + properties: OpenTemplateItemProps; +} + export interface ActionCreateElement extends CreateElementBase { type: 'action'; subcategory: string; @@ -873,7 +887,8 @@ export type INodeCreateElement = | ViewCreateElement | LabelCreateElement | ActionCreateElement - | LinkCreateElement; + | LinkCreateElement + | OpenTemplateElement; export type NodeTypeSelectedPayload = { type: string; diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/OpenTemplateItem.vue b/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/OpenTemplateItem.vue new file mode 100644 index 0000000000..ebb84048fb --- /dev/null +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/OpenTemplateItem.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue index c32ae9c223..00b931e647 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue @@ -27,6 +27,7 @@ import { filterAndSearchNodes, prepareCommunityNodeDetailsViewStack, transformNodeType, + getRootSearchCallouts, } from '../utils'; import { useViewStacks } from '../composables/useViewStacks'; import { useKeyboardNavigation } from '../composables/useKeyboardNavigation'; @@ -42,6 +43,7 @@ import { SEND_AND_WAIT_OPERATION, type INodeParameters } from 'n8n-workflow'; import { isCommunityPackageName } from '@/utils/nodeTypesUtils'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; +import { useCalloutHelpers } from '@/composables/useCalloutHelpers'; export interface Props { rootView: 'trigger' | 'action'; @@ -53,6 +55,8 @@ const emit = defineEmits<{ const i18n = useI18n(); +const calloutHelpers = useCalloutHelpers(); + const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore(); const { pushViewStack, popViewStack, isAiSubcategoryView } = useViewStacks(); const { setAddedNodeActionParameters, nodeCreateElementToNodeTypeSelectedPayload } = useActions(); @@ -76,7 +80,10 @@ const moreFromCommunity = computed(() => { const isSearchResultEmpty = computed(() => { return ( (activeViewStack.value.items || []).length === 0 && - globalSearchItemsDiff.value.length + moreFromCommunity.value.length === 0 + globalCallouts.value.length + + globalSearchItemsDiff.value.length + + moreFromCommunity.value.length === + 0 ); }); @@ -216,6 +223,12 @@ function onSelected(item: INodeCreateElement) { if (item.type === 'link') { window.open(item.properties.url, '_blank'); } + + if (item.type === 'openTemplate') { + if (item.properties.key === 'rag-starter-template') { + void calloutHelpers.openRagStarterTemplate(); + } + } } function subcategoriesMapper(item: INodeCreateElement) { @@ -254,6 +267,12 @@ function baseSubcategoriesFilter(item: INodeCreateElement): boolean { return hasActions || !hasTriggerGroup; } +const globalCallouts = computed(() => + getRootSearchCallouts(activeViewStack.value.search ?? '', { + isRagStarterCalloutVisible: calloutHelpers.isRagStarterCalloutVisible.value, + }), +); + function arrowLeft() { popViewStack(); } @@ -286,6 +305,9 @@ registerKeyHook('MainViewArrowLeft', {