feat(editor): Add RAG starter template callouts experiment (#16282)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
This commit is contained in:
Jaakko Husso
2025-06-13 17:45:30 +03:00
committed by GitHub
parent 30148df7f3
commit d0a313aa1c
27 changed files with 1032 additions and 24 deletions

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { OpenTemplateItemProps } from '@/Interface';
export interface Props {
openTemplate: OpenTemplateItemProps;
}
defineProps<Props>();
</script>
<template>
<n8n-node-creator-node
:class="$style.creatorOpenTemplate"
:title="openTemplate.title"
:is-trigger="false"
:description="openTemplate.description"
:tag="openTemplate.tag"
:show-action-arrow="true"
>
<template #icon>
<n8n-node-icon type="icon" :name="openTemplate.icon" :circle="false" :show-tooltip="false" />
</template>
</n8n-node-creator-node>
</template>
<style lang="scss" module>
.creatorOpenTemplate {
--action-arrow-color: var(--color-text-light);
margin-left: var(--spacing-s);
margin-right: var(--spacing-xs);
}
</style>

View File

@@ -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<INodeCreateElement[]>(() =>
getRootSearchCallouts(activeViewStack.value.search ?? '', {
isRagStarterCalloutVisible: calloutHelpers.isRagStarterCalloutVisible.value,
}),
);
function arrowLeft() {
popViewStack();
}
@@ -286,6 +305,9 @@ registerKeyHook('MainViewArrowLeft', {
<template>
<span>
<!-- Global Callouts-->
<ItemsRenderer :elements="globalCallouts" :class="$style.items" @selected="onSelected" />
<!-- Main Node Items -->
<ItemsRenderer
v-memo="[activeViewStack.search]"

View File

@@ -10,6 +10,12 @@ import { REGULAR_NODE_CREATOR_VIEW } from '@/constants';
import type { NodeFilterType } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render';
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({ query: {}, params: {} })),
useRouter: vi.fn(),
RouterLink: vi.fn(),
}));
function getWrapperComponent(setup: () => void) {
const wrapperComponent = defineComponent({
components: {

View File

@@ -13,6 +13,7 @@ import CommunityNodeItem from '../ItemTypes/CommunityNodeItem.vue';
import CategorizedItemsRenderer from './CategorizedItemsRenderer.vue';
import { useViewStacks } from '../composables/useViewStacks';
import OpenTemplateItem from '../ItemTypes/OpenTemplateItem.vue';
export interface Props {
elements?: INodeCreateElement[];
@@ -206,6 +207,12 @@ watch(
:link="item.properties"
:class="$style.linkItem"
/>
<OpenTemplateItem
v-else-if="item.type === 'openTemplate'"
:open-template="item.properties"
:class="$style.linkItem"
/>
</div>
</div>
<n8n-loading v-else :loading="true" :rows="1" variant="p" :class="$style.itemSkeleton" />

View File

@@ -305,3 +305,26 @@ export function prepareCommunityNodeDetailsViewStack(
communityNodeDetails,
};
}
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({
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'),
},
},
});
}
return results;
}

View File

@@ -33,6 +33,32 @@ export const TEST_PARAMETERS: INodeProperties[] = [
},
],
},
{
displayName:
'Note: This is a notice with <a href="notice.n8n.io" target="_blank">notice link</a>',
name: 'testNotice',
type: 'notice',
default: '',
displayOptions: {
show: { '@version': [{ _cnd: { gte: 1.1 } }] },
},
},
{
displayName:
'Tip: This is a callout with <a href="callout.n8n.io" target="_blank">callout link</a>',
name: 'testCallout',
type: 'callout',
default: '',
displayOptions: {
show: { '@version': [{ _cnd: { gte: 1.1 } }] },
},
typeOptions: {
calloutAction: {
label: 'and action!',
type: 'openRagStarterTemplate',
},
},
},
];
export const FIXED_COLLECTION_PARAMETERS: INodeProperties[] = TEST_PARAMETERS.filter(

View File

@@ -83,9 +83,10 @@ describe('ParameterInputList', () => {
});
// Should render labels for all parameters
TEST_PARAMETERS.forEach((parameter) => {
FIXED_COLLECTION_PARAMETERS.forEach((parameter) => {
expect(getByText(parameter.displayName)).toBeInTheDocument();
});
// Should render input placeholders for all fixed collection parameters
expect(getAllByTestId('suspense-stub')).toHaveLength(FIXED_COLLECTION_PARAMETERS.length);
});
@@ -100,7 +101,7 @@ describe('ParameterInputList', () => {
});
// Should render labels for all parameters
TEST_PARAMETERS.forEach((parameter) => {
FIXED_COLLECTION_PARAMETERS.forEach((parameter) => {
expect(getByText(parameter.displayName)).toBeInTheDocument();
});
// Should render error message for fixed collection parameter
@@ -110,6 +111,35 @@ describe('ParameterInputList', () => {
expect(getByText(TEST_ISSUE)).toBeInTheDocument();
});
it('renders notice correctly', () => {
ndvStore.activeNode = TEST_NODE_NO_ISSUES;
const { getByText } = renderComponent({
props: {
parameters: TEST_PARAMETERS,
nodeValues: TEST_NODE_VALUES,
},
});
expect(getByText('Note: This is a notice with')).toBeInTheDocument();
expect(getByText('notice link')).toBeInTheDocument();
expect(getByText('notice link').getAttribute('href')).toEqual('notice.n8n.io');
});
it('renders callout correctly', () => {
ndvStore.activeNode = TEST_NODE_NO_ISSUES;
const { getByTestId, getByText } = renderComponent({
props: {
parameters: TEST_PARAMETERS,
nodeValues: TEST_NODE_VALUES,
},
});
expect(getByText('Tip: This is a callout with')).toBeInTheDocument();
expect(getByText('callout link')).toBeInTheDocument();
expect(getByText('callout link').getAttribute('href')).toEqual('callout.n8n.io');
expect(getByText('and action!')).toBeInTheDocument();
expect(getByTestId('callout-dismiss-icon')).toBeInTheDocument();
});
describe('updateFormParameters', () => {
const workflowHelpersMock: MockInstance = vi.spyOn(workflowHelpers, 'useWorkflowHelpers');
const formParameters = [

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type {
CalloutActionType,
INodeParameters,
INodeProperties,
NodeParameterValue,
@@ -20,10 +21,12 @@ import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import { useI18n } from '@n8n/i18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useMessage } from '@/composables/useMessage';
import {
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
KEEP_AUTH_IN_NDV_FOR_NODES,
MODAL_CONFIRM,
WAIT_NODE_TYPE,
} from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
@@ -38,8 +41,17 @@ import { captureException } from '@sentry/vue';
import { computedWithControl } from '@vueuse/core';
import get from 'lodash/get';
import set from 'lodash/set';
import { N8nIcon, N8nIconButton, N8nInputLabel, N8nNotice, N8nText } from '@n8n/design-system';
import {
N8nCallout,
N8nIcon,
N8nIconButton,
N8nInputLabel,
N8nLink,
N8nNotice,
N8nText,
} from '@n8n/design-system';
import { storeToRefs } from 'pinia';
import { useCalloutHelpers } from '@/composables/useCalloutHelpers';
const LazyFixedCollectionParameter = defineAsyncComponent(
async () => await import('./FixedCollectionParameter.vue'),
@@ -72,10 +84,13 @@ const emit = defineEmits<{
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const message = useMessage();
const nodeHelpers = useNodeHelpers();
const asyncLoadingError = ref(false);
const workflowHelpers = useWorkflowHelpers();
const i18n = useI18n();
const { dismissCallout, isCalloutDismissed, openRagStarterTemplate, isRagStarterCalloutVisible } =
useCalloutHelpers();
const { activeNode } = storeToRefs(ndvStore);
@@ -525,6 +540,47 @@ function getParameterValue<T extends NodeParameterValueType = NodeParameterValue
): T {
return nodeHelpers.getParameterValue(props.nodeValues, name, props.path) as T;
}
function isRagStarterCallout(parameter: INodeProperties): boolean {
return parameter.type === 'callout' && parameter.name === 'ragStarterCallout';
}
function isCalloutVisible(parameter: INodeProperties): boolean {
if (isCalloutDismissed(parameter.name)) return false;
if (isRagStarterCallout(parameter)) {
return isRagStarterCalloutVisible.value;
}
return true;
}
async function onCalloutAction(action: CalloutActionType) {
if (action === 'openRagStarterTemplate') {
await openRagStarterTemplate(activeNode.value?.type ?? 'no active node');
}
}
const onCalloutDismiss = async (parameter: INodeProperties) => {
const dismissConfirmed = await message.confirm(
i18n.baseText('parameterInputList.callout.dismiss.confirm.text'),
{
showClose: true,
confirmButtonText: i18n.baseText(
'parameterInputList.callout.dismiss.confirm.confirmButtonText',
),
cancelButtonText: i18n.baseText(
'parameterInputList.callout.dismiss.confirm.cancelButtonText',
),
},
);
if (dismissConfirmed !== MODAL_CONFIRM) {
return;
}
await dismissCallout(parameter.name);
};
</script>
<template>
@@ -564,6 +620,46 @@ function getParameterValue<T extends NodeParameterValueType = NodeParameterValue
@action="onNoticeAction"
/>
<template v-else-if="parameter.type === 'callout'">
<N8nCallout
v-if="isCalloutVisible(parameter)"
:class="['parameter-item', parameter.typeOptions?.containerClass ?? '']"
theme="secondary"
>
<N8nText size="small">
<N8nText
size="small"
v-n8n-html="i18n.nodeText(activeNode?.type).inputLabelDisplayName(parameter, path)"
/>
<template v-if="parameter.typeOptions?.calloutAction">
{{ ' ' }}
<N8nLink
v-if="parameter.typeOptions?.calloutAction"
theme="secondary"
size="small"
:bold="true"
:underline="true"
@click="onCalloutAction(parameter.typeOptions.calloutAction.type)"
>
{{ parameter.typeOptions.calloutAction.label }}
</N8nLink>
</template>
</N8nText>
<template #trailingContent>
<N8nIcon
icon="times"
title="Dismiss"
size="medium"
type="secondary"
class="callout-dismiss"
data-test-id="callout-dismiss-icon"
@click="onCalloutDismiss(parameter)"
/>
</template>
</N8nCallout>
</template>
<div v-else-if="parameter.type === 'button'" class="parameter-item">
<ButtonParameter
:parameter="parameter"
@@ -766,5 +862,14 @@ function getParameterValue<T extends NodeParameterValueType = NodeParameterValue
display: block;
padding: var(--spacing-3xs) 0;
}
.callout-dismiss {
margin-left: var(--spacing-xs);
line-height: 1;
cursor: pointer;
}
.callout-dismiss:hover {
color: var(--color-icon-hover);
}
}
</style>