feat(editor): Node Creator AI nodes improvements (#9484)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg
2024-05-30 16:53:33 +02:00
committed by GitHub
parent e68a3fd6ce
commit be4f54de15
62 changed files with 661 additions and 204 deletions

View File

@@ -9,7 +9,7 @@ import type {
VIEWS,
ROLE,
} from '@/constants';
import type { IMenuItem } from 'n8n-design-system';
import type { IMenuItem, NodeCreatorTag } from 'n8n-design-system';
import {
type GenericValue,
type IConnections,
@@ -932,7 +932,9 @@ export type SimplifiedNodeType = Pick<
| 'codex'
| 'defaults'
| 'outputs'
>;
> & {
tag?: string;
};
export interface SubcategoryItemProps {
description?: string;
iconType?: string;
@@ -951,11 +953,21 @@ export interface ViewItemProps {
title: string;
description: string;
icon: string;
tag?: string;
tag?: NodeCreatorTag;
borderless?: boolean;
}
export interface LabelItemProps {
key: string;
}
export interface LinkItemProps {
url: string;
key: string;
newTab?: boolean;
title: string;
description: string;
icon: string;
tag?: NodeCreatorTag;
}
export interface ActionTypeDescription extends SimplifiedNodeType {
displayOptions?: IDisplayOptions;
values?: IDataObject;
@@ -1010,6 +1022,11 @@ export interface LabelCreateElement extends CreateElementBase {
properties: LabelItemProps;
}
export interface LinkCreateElement extends CreateElementBase {
type: 'link';
properties: LinkItemProps;
}
export interface ActionCreateElement extends CreateElementBase {
type: 'action';
subcategory: string;
@@ -1023,7 +1040,8 @@ export type INodeCreateElement =
| SectionCreateElement
| ViewCreateElement
| LabelCreateElement
| ActionCreateElement;
| ActionCreateElement
| LinkCreateElement;
export interface SubcategorizedNodeTypes {
[subcategory: string]: INodeCreateElement[];

View File

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

View File

@@ -8,6 +8,7 @@
:show-action-arrow="showActionArrow"
:is-trigger="isTrigger"
:data-test-id="dataTestId"
:tag="nodeType.tag"
@dragstart="onDragStart"
@dragend="onDragEnd"
>

View File

@@ -139,6 +139,13 @@ function onSelected(item: INodeCreateElement) {
searchItems: mergedNodes,
});
}
if (item.type === 'link') {
window.open(item.properties.url, '_blank');
telemetry.trackNodesPanel('nodeCreateList.onLinkSelected', {
link: item.properties.url,
});
}
}
function subcategoriesMapper(item: INodeCreateElement) {
@@ -195,13 +202,13 @@ function onKeySelect(activeItemId: string) {
registerKeyHook('MainViewArrowRight', {
keyboardKeys: ['ArrowRight', 'Enter'],
condition: (type) => ['subcategory', 'node', 'view'].includes(type),
condition: (type) => ['subcategory', 'node', 'link', 'view'].includes(type),
handler: onKeySelect,
});
registerKeyHook('MainViewArrowLeft', {
keyboardKeys: ['ArrowLeft'],
condition: (type) => ['subcategory', 'node', 'view'].includes(type),
condition: (type) => ['subcategory', 'node', 'link', 'view'].includes(type),
handler: arrowLeft,
});
</script>

View File

@@ -8,6 +8,7 @@ import SubcategoryItem from '../ItemTypes/SubcategoryItem.vue';
import LabelItem from '../ItemTypes/LabelItem.vue';
import ActionItem from '../ItemTypes/ActionItem.vue';
import ViewItem from '../ItemTypes/ViewItem.vue';
import LinkItem from '../ItemTypes/LinkItem.vue';
import CategorizedItemsRenderer from './CategorizedItemsRenderer.vue';
export interface Props {
@@ -147,6 +148,8 @@ watch(
[$style.active]: activeItemId === item.uuid,
[$style.iteratorItem]: true,
[$style[item.type]]: true,
// Borderless is only applied to views
[$style.borderless]: item.type === 'view' && item.properties.borderless === true,
}"
data-test-id="item-iterator-item"
:data-keyboard-nav-type="item.type !== 'label' ? item.type : undefined"
@@ -175,6 +178,12 @@ watch(
:view="item.properties"
:class="$style.viewItem"
/>
<LinkItem
v-else-if="item.type === 'link'"
:link="item.properties"
:class="$style.linkItem"
/>
</div>
</div>
<n8n-loading v-else :loading="true" :rows="1" variant="p" :class="$style.itemSkeleton" />
@@ -223,12 +232,14 @@ watch(
display: none;
}
}
.view {
position: relative;
&:last-child {
margin-top: var(--spacing-s);
padding-top: var(--spacing-xs);
&:after {
content: '';
position: absolute;
@@ -241,4 +252,34 @@ watch(
}
}
}
.link {
position: relative;
&:last-child {
margin-bottom: var(--spacing-s);
padding-bottom: var(--spacing-xs);
&:after {
content: '';
position: absolute;
left: var(--spacing-s);
right: var(--spacing-s);
top: 0;
margin: auto;
bottom: 0;
border-bottom: 1px solid var(--color-foreground-base);
}
}
}
.borderless {
&:last-child {
margin-top: 0;
padding-top: 0;
&:after {
content: none;
}
}
}
</style>

View File

@@ -76,7 +76,7 @@ describe('NodesListPanel', () => {
await fireEvent.click(container.querySelector('.backButton')!);
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(7);
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8);
});
it('should render regular nodes', async () => {
@@ -136,7 +136,7 @@ describe('NodesListPanel', () => {
await nextTick();
expect(screen.getByText('What happens next?')).toBeInTheDocument();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(6);
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(5);
screen.getByText('Action in an app').click();
await nextTick();

View File

@@ -1,19 +1,29 @@
import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
import type {
INodeCreateElement,
NodeCreateElement,
NodeFilterType,
SimplifiedNodeType,
} from '@/Interface';
import {
AI_CATEGORY_ROOT_NODES,
AI_CODE_NODE_TYPE,
AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
AI_SUBCATEGORY,
DEFAULT_SUBCATEGORY,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/constants';
import { defineStore } from 'pinia';
import { v4 as uuid } from 'uuid';
import { computed, nextTick, ref } from 'vue';
import difference from 'lodash-es/difference';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import {
flattenCreateElements,
groupItemsInSections,
isAINode,
searchNodes,
sortNodeCreateElements,
subcategorizeItems,
@@ -27,6 +37,7 @@ import { useKeyboardNavigation } from './useKeyboardNavigation';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { INodeInputFilter, NodeConnectionType } from 'n8n-workflow';
import { useCanvasStore } from '@/stores/canvas.store';
interface ViewStack {
uuid?: string;
@@ -60,11 +71,12 @@ interface ViewStack {
export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
const nodeCreatorStore = useNodeCreatorStore();
const { getActiveItemIndex } = useKeyboardNavigation();
const i18n = useI18n();
const viewStacks = ref<ViewStack[]>([]);
const activeStackItems = computed<INodeCreateElement[]>(() => {
const stack = viewStacks.value[viewStacks.value.length - 1];
const stack = getLastActiveStack();
if (!stack?.baselineItems) {
return stack.items ? extendItemsWithUUID(stack.items) : [];
@@ -76,13 +88,24 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
? searchBaseItems.value
: flattenCreateElements(stack.baselineItems ?? []);
return extendItemsWithUUID(searchNodes(stack.search || '', searchBase));
const canvasHasAINodes = useCanvasStore().aiNodes.length > 0;
const filteredNodes =
isAiRootView(stack) || canvasHasAINodes ? searchBase : filterOutAiNodes(searchBase);
const searchResults = extendItemsWithUUID(searchNodes(stack.search || '', filteredNodes));
const groupedNodes = groupIfAiNodes(searchResults, false) ?? searchResults;
// Set the active index to the second item if there's a section
// as the first item is collapsable
stack.activeIndex = groupedNodes.some((node) => node.type === 'section') ? 1 : 0;
return groupedNodes;
}
return extendItemsWithUUID(stack.baselineItems);
return extendItemsWithUUID(groupIfAiNodes(stack.baselineItems, true));
});
const activeViewStack = computed<ViewStack>(() => {
const stack = viewStacks.value[viewStacks.value.length - 1];
const stack = getLastActiveStack();
if (!stack) return {};
const flatBaselineItems = flattenCreateElements(stack.baselineItems ?? []);
@@ -99,34 +122,148 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
);
const searchBaseItems = computed<INodeCreateElement[]>(() => {
const stack = viewStacks.value[viewStacks.value.length - 1];
const stack = getLastActiveStack();
if (!stack?.searchItems) return [];
return stack.searchItems.map((item) => transformNodeType(item, stack.subcategory));
});
function getLastActiveStack() {
return viewStacks.value[viewStacks.value.length - 1];
}
// Generate a delta between the global search results(all nodes) and the stack search results
const globalSearchItemsDiff = computed<INodeCreateElement[]>(() => {
const stack = viewStacks.value[viewStacks.value.length - 1];
const stack = getLastActiveStack();
if (!stack?.search) return [];
const allNodes = nodeCreatorStore.mergedNodes.map((item) => transformNodeType(item));
const globalSearchResult = extendItemsWithUUID(searchNodes(stack.search || '', allNodes));
// Apply filtering for AI nodes if the current view is not the AI root view
const filteredNodes = isAiRootView(stack) ? allNodes : filterOutAiNodes(allNodes);
return globalSearchResult.filter((item) => {
return !activeStackItems.value.find((activeItem) => activeItem.key === item.key);
let globalSearchResult: INodeCreateElement[] = extendItemsWithUUID(
searchNodes(stack.search || '', filteredNodes),
);
if (isAiRootView(stack)) {
globalSearchResult = groupIfAiNodes(globalSearchResult);
}
const filteredItems = globalSearchResult.filter((item) => {
return !activeStackItems.value.find((activeItem) => {
if (activeItem.type === 'section') {
const matchingSectionItem = activeItem.children.some(
(sectionItem) => sectionItem.key === item.key,
);
return matchingSectionItem;
}
return activeItem.key === item.key;
});
});
// Filter out empty sections if all of their children are filtered out
const filteredSections = filteredItems.filter((item) => {
if (item.type === 'section') {
const hasVisibleChildren = item.children.some((child) =>
activeStackItems.value.some((filteredItem) => filteredItem.key === child.key),
);
return hasVisibleChildren;
}
return true;
});
return filteredSections;
});
const itemsBySubcategory = computed(() => subcategorizeItems(nodeCreatorStore.mergedNodes));
function isAiRootView(stack: ViewStack) {
return stack.rootView === AI_NODE_CREATOR_VIEW;
}
function groupIfAiNodes(items: INodeCreateElement[], sortAlphabetically = true) {
const aiNodes = items.filter((node): node is NodeCreateElement => isAINode(node));
if (aiNodes.length > 0) {
const sectionsMap = new Map<string, NodeViewItemSection>();
aiNodes.forEach((node) => {
const section = node.properties.codex?.subcategories?.[AI_SUBCATEGORY]?.[0];
if (section) {
const currentItems = sectionsMap.get(section)?.items ?? [];
const isSubnodesSection =
!node.properties.codex?.subcategories?.[AI_SUBCATEGORY].includes(
AI_CATEGORY_ROOT_NODES,
);
sectionsMap.set(section, {
key: section,
title: isSubnodesSection
? `${section} (${i18n.baseText('nodeCreator.subnodes')})`
: section,
items: [...currentItems, node.key],
});
}
});
const nonAiNodes = difference(items, aiNodes);
const nonAiTriggerNodes = nonAiNodes.filter(
(item) => item.type === 'node' && useNodeTypesStore().isTriggerNode(item.properties.name),
);
const nonAiRegularNodes = difference(nonAiNodes, nonAiTriggerNodes);
if (nonAiNodes.length > 0) {
let sectionKey = '';
if (nonAiRegularNodes.length && nonAiTriggerNodes.length) {
sectionKey = i18n.baseText('nodeCreator.actionsCategory.regularAndTriggers');
} else {
sectionKey = nonAiRegularNodes.length
? i18n.baseText('nodeCreator.actionsCategory.regularNodes')
: i18n.baseText('nodeCreator.actionsCategory.triggerNodes');
}
const nodesKeys = nonAiNodes.map((node) => node.key);
sectionsMap.set(sectionKey, {
key: sectionKey,
title: sectionKey,
items: [...nodesKeys],
});
}
// Convert sectionsMap to array of sections
const sections = Array.from(sectionsMap.values());
return groupItemsInSections(items, sections, sortAlphabetically);
}
return items;
}
function filterOutAiNodes(items: INodeCreateElement[]) {
const filteredSearchBase = items.filter((item) => {
if (item.type === 'node') {
const isAICategory = item.properties.codex?.categories?.includes(AI_SUBCATEGORY) === true;
if (!isAICategory) return true;
const isRootNodeSubcategory =
item.properties.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_ROOT_NODES);
return isRootNodeSubcategory;
}
return true;
});
return filteredSearchBase;
}
async function gotoCompatibleConnectionView(
connectionType: NodeConnectionType,
isOutput?: boolean,
filter?: INodeInputFilter,
) {
const i18n = useI18n();
let nodesByConnectionType: { [key: string]: string[] };
let relatedAIView: { properties: NodeViewItem['properties'] } | undefined;
@@ -185,7 +322,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
}
function setStackBaselineItems() {
const stack = viewStacks.value[viewStacks.value.length - 1];
const stack = getLastActiveStack();
if (!stack || !activeViewStack.value.uuid) return;
let stackItems = stack?.items ?? [];
@@ -258,7 +395,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
}
function updateCurrentViewStack(stack: Partial<ViewStack>) {
const currentStack = viewStacks.value[viewStacks.value.length - 1];
const currentStack = getLastActiveStack();
const matchedIndex = viewStacks.value.findIndex((s) => s.uuid === currentStack.uuid);
if (!currentStack) return;

View File

@@ -6,12 +6,18 @@ import type {
INodeCreateElement,
SectionCreateElement,
} from '@/Interface';
import { AI_SUBCATEGORY, CORE_NODES_CATEGORY, DEFAULT_SUBCATEGORY } from '@/constants';
import {
AI_CATEGORY_AGENTS,
AI_SUBCATEGORY,
CORE_NODES_CATEGORY,
DEFAULT_SUBCATEGORY,
} from '@/constants';
import { v4 as uuidv4 } from 'uuid';
import { sublimeSearch } from '@/utils/sortUtils';
import { i18n } from '@/plugins/i18n';
import type { NodeViewItemSection } from './viewsData';
import { i18n } from '@/plugins/i18n';
import { sortBy } from 'lodash-es';
export function transformNodeType(
node: SimplifiedNodeType,
@@ -70,6 +76,7 @@ export function sortNodeCreateElements(nodes: INodeCreateElement[]) {
export function searchNodes(searchFilter: string, items: INodeCreateElement[]) {
// In order to support the old search we need to remove the 'trigger' part
const trimmedFilter = searchFilter.toLowerCase().replace('trigger', '').trimEnd();
const result = (
sublimeSearch<INodeCreateElement>(trimmedFilter, items, [
{ key: 'properties.displayName', weight: 1.3 },
@@ -83,38 +90,72 @@ export function searchNodes(searchFilter: string, items: INodeCreateElement[]) {
export function flattenCreateElements(items: INodeCreateElement[]): INodeCreateElement[] {
return items.map((item) => (item.type === 'section' ? item.children : item)).flat();
}
export function isAINode(node: INodeCreateElement) {
const isNode = node.type === 'node';
if (!isNode) return false;
if (node.properties.codex?.categories?.includes(AI_SUBCATEGORY)) {
const isAgentSubcategory =
node.properties.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
return !isAgentSubcategory;
}
return false;
}
export function groupItemsInSections(
items: INodeCreateElement[],
sections: string[] | NodeViewItemSection[],
sortAlphabetically = true,
): INodeCreateElement[] {
const filteredSections = sections.filter(
(section): section is NodeViewItemSection => typeof section === 'object',
);
const itemsBySection = items.reduce((acc: Record<string, INodeCreateElement[]>, item) => {
const section = filteredSections.find((s) => s.items.includes(item.key));
const key = section?.key ?? 'other';
acc[key] = [...(acc[key] ?? []), item];
return acc;
}, {});
const itemsBySection = (items2: INodeCreateElement[]) =>
items2.reduce((acc: Record<string, INodeCreateElement[]>, item) => {
const section = filteredSections.find((s) => s.items.includes(item.key));
const result: SectionCreateElement[] = filteredSections
.map(
const key = section?.key ?? 'other';
if (key) {
acc[key] = [...(acc[key] ?? []), item];
}
return acc;
}, {});
const mapNewSections = (
newSections: NodeViewItemSection[],
children: Record<string, INodeCreateElement[]>,
) =>
newSections.map(
(section): SectionCreateElement => ({
type: 'section',
key: section.key,
title: section.title,
children: sortNodeCreateElements(itemsBySection[section.key] ?? []),
children: sortAlphabetically
? sortNodeCreateElements(children[section.key] ?? [])
: children[section.key] ?? [],
}),
)
);
const nonAINodes = items.filter((item) => !isAINode(item));
const AINodes = items.filter((item) => isAINode(item));
const nonAINodesBySection = itemsBySection(nonAINodes);
const nonAINodesSections = mapNewSections(filteredSections, nonAINodesBySection);
const AINodesBySection = itemsBySection(AINodes);
const AINodesSections = mapNewSections(sortBy(filteredSections, ['title']), AINodesBySection);
const result = [...nonAINodesSections, ...AINodesSections]
.concat({
type: 'section',
key: 'other',
title: i18n.baseText('nodeCreator.sectionNames.other'),
children: sortNodeCreateElements(itemsBySection.other ?? []),
children: sortNodeCreateElements(nonAINodesBySection.other ?? []),
})
.filter((section) => section.children.length > 0);
.filter((section) => section.type !== 'section' || section.children.length > 0);
if (result.length <= 1) {
return items;

View File

@@ -5,10 +5,10 @@ import {
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
REGULAR_NODE_CREATOR_VIEW,
TRANSFORM_DATA_SUBCATEGORY,
FILES_SUBCATEGORY,
FLOWS_CONTROL_SUBCATEGORY,
TRIGGER_NODE_CREATOR_VIEW,
EMAIL_IMAP_NODE_TYPE,
@@ -52,6 +52,8 @@ import {
EMAIL_SEND_NODE_TYPE,
EDIT_IMAGE_NODE_TYPE,
COMPRESSION_NODE_TYPE,
AI_CODE_TOOL_LANGCHAIN_NODE_TYPE,
AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
} from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@@ -76,13 +78,17 @@ export interface NodeViewItem {
iconProps?: {
color?: string;
};
url?: string;
connectionType?: NodeConnectionType;
panelClass?: string;
group?: string[];
sections?: NodeViewItemSection[];
description?: string;
displayName?: string;
tag?: string;
tag?: {
type: string;
text: string;
};
forceIncludeNodes?: string[];
iconData?: {
type: string;
@@ -141,12 +147,24 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
value: AI_NODE_CREATOR_VIEW,
title: i18n.baseText('nodeCreator.aiPanel.aiNodes'),
subtitle: i18n.baseText('nodeCreator.aiPanel.selectAiNode'),
info: i18n.baseText('nodeCreator.aiPanel.infoBox', {
interpolate: { link: templatesStore.getWebsiteCategoryURL('ai') },
}),
items: [
...chainNodes,
{
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: templatesStore.getWebsiteCategoryURL(undefined, 'AdvancedAI'),
tag: {
type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),
},
},
},
...agentNodes,
...chainNodes,
{
key: AI_OTHERS_NODE_CREATOR_VIEW,
type: 'view',
@@ -159,6 +177,7 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
],
};
}
export function AINodesView(_nodes: SimplifiedNodeType[]): NodeView {
const i18n = useI18n();
@@ -232,12 +251,20 @@ export function AINodesView(_nodes: SimplifiedNodeType[]): NodeView {
},
},
{
key: AI_CATEGORY_TOOLS,
type: 'subcategory',
key: AI_CATEGORY_TOOLS,
category: CORE_NODES_CATEGORY,
properties: {
title: AI_CATEGORY_TOOLS,
icon: 'tools',
...getAISubcategoryProperties(NodeConnectionType.AiTool),
sections: [
{
key: 'popular',
title: i18n.baseText('nodeCreator.sectionNames.popular'),
items: [AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, AI_CODE_TOOL_LANGCHAIN_NODE_TYPE],
},
],
},
},
{
@@ -278,6 +305,18 @@ export function TriggerView() {
title: i18n.baseText('nodeCreator.triggerHelperPanel.selectATrigger'),
subtitle: i18n.baseText('nodeCreator.triggerHelperPanel.selectATriggerDescription'),
items: [
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
icon: 'fa:mouse-pointer',
},
},
{
key: DEFAULT_SUBCATEGORY,
type: 'subcategory',
@@ -331,18 +370,6 @@ export function TriggerView() {
},
},
},
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
icon: 'fa:mouse-pointer',
},
},
{
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type: 'node',
@@ -355,6 +382,18 @@ export function TriggerView() {
icon: 'fa:sign-out-alt',
},
},
{
key: MANUAL_CHAT_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
group: [],
name: MANUAL_CHAT_TRIGGER_NODE_TYPE,
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName'),
description: i18n.baseText('nodeCreator.triggerHelperPanel.manualChatTriggerDescription'),
icon: 'fa:comments',
},
},
{
type: 'subcategory',
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
@@ -447,22 +486,6 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
],
},
},
{
type: 'subcategory',
key: FILES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
title: FILES_SUBCATEGORY,
icon: 'file-alt',
sections: [
{
key: 'popular',
title: i18n.baseText('nodeCreator.sectionNames.popular'),
items: [CONVERT_TO_FILE_NODE_TYPE, EXTRACT_FROM_FILE_NODE_TYPE],
},
],
},
},
{
type: 'subcategory',
key: HELPERS_SUBCATEGORY,
@@ -491,9 +514,13 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
title: i18n.baseText('nodeCreator.aiPanel.langchainAiNodes'),
icon: 'robot',
description: i18n.baseText('nodeCreator.aiPanel.nodesForAi'),
tag: i18n.baseText('nodeCreator.aiPanel.newTag'),
tag: {
type: 'success',
text: i18n.baseText('nodeCreator.aiPanel.newTag'),
},
borderless: true,
},
});
} as NodeViewItem);
view.items.push({
key: TRIGGER_NODE_CREATOR_VIEW,

View File

@@ -264,8 +264,10 @@ export const AI_CATEGORY_RETRIEVERS = 'Retrievers';
export const AI_CATEGORY_EMBEDDING = 'Embeddings';
export const AI_CATEGORY_DOCUMENT_LOADERS = 'Document Loaders';
export const AI_CATEGORY_TEXT_SPLITTERS = 'Text Splitters';
export const AI_CATEGORY_ROOT_NODES = 'Root Nodes';
export const AI_UNCATEGORIZED_CATEGORY = 'Miscellaneous';
export const AI_CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCode';
export const AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolWorkflow';
export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3';
// Node Connection Types
@@ -674,10 +676,17 @@ export const AI_ASSISTANT_EXPERIMENT = {
variant: 'variant',
};
export const CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT = {
name: '20_canvas_auto_add_manual_trigger',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [
ASK_AI_EXPERIMENT.name,
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,
AI_ASSISTANT_EXPERIMENT.name,
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name,
];
export const MFA_AUTHENTICATION_REQUIRED_ERROR_CODE = 998;

View File

@@ -928,9 +928,9 @@
"ndv.output.of": " of ",
"ndv.output.pageSize": "Page Size",
"ndv.output.run": "Run",
"ndv.output.runNodeHint": "Test this node to output data",
"ndv.output.runNodeHint": "Execute this node to view data",
"ndv.output.runNodeHintSubNode": "Output will appear here once the parent node is run",
"ndv.output.insertTestData": "insert test data",
"ndv.output.insertTestData": "set mock data",
"ndv.output.staleDataWarning.regular": "Node parameters have changed.<br>Test node again to refresh output.",
"ndv.output.staleDataWarning.pinData": "Node parameter changes will not affect pinned output data.",
"ndv.output.tooMuchData.message": "The node contains {size} MB of data. Displaying it may cause problems. <br /> If you do decide to display it, avoid the JSON view.",
@@ -986,6 +986,9 @@
"nodeCreator.actionsCategory.onNewEvent": "On new {event} event",
"nodeCreator.actionsCategory.onEvent": "On {event}",
"nodeCreator.actionsCategory.triggers": "Triggers",
"nodeCreator.actionsCategory.triggerNodes": "Trigger Nodes",
"nodeCreator.actionsCategory.regularNodes": "Regular Nodes",
"nodeCreator.actionsCategory.regularAndTriggers": "Regular & Trigger Nodes",
"nodeCreator.actionsCategory.searchActions": "Search {node} Actions...",
"nodeCreator.actionsCategory.noMatchingActions": "No matching Actions. <i>Reset search</i>",
"nodeCreator.actionsCategory.noMatchingTriggers": "No matching Triggers. <i>Reset search</i>",
@@ -996,6 +999,7 @@
"nodeCreator.actionsTooltip.actionsPerformStep": "Actions perform a step once your workflow has already started. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/\"> Learn more</a>",
"nodeCreator.actionsCallout.noTriggerItems": "No <strong>{nodeName}</strong> Triggers available. Users often combine the following Triggers with <strong>{nodeName}</strong> Actions.",
"nodeCreator.categoryNames.otherCategories": "Results in other categories",
"nodeCreator.subnodes": "sub-nodes",
"nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe": "Dont worry, you can probably do it with the",
"nodeCreator.noResults.httpRequest": "HTTP Request",
"nodeCreator.noResults.node": "node",
@@ -1007,10 +1011,10 @@
"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",
"nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate data, run JavaScript code, etc.",
"nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate, filter or convert data",
"nodeCreator.subcategoryDescriptions.files": "CSV, XLS, XML, text, images, etc.",
"nodeCreator.subcategoryDescriptions.flow": "IF, Switch, Wait, Compare and Merge data, etc.",
"nodeCreator.subcategoryDescriptions.helpers": "Code, HTTP Requests (API Calls), Webhook, and other helpers",
"nodeCreator.subcategoryDescriptions.flow": "Branch, merge or loop the flow, etc.",
"nodeCreator.subcategoryDescriptions.helpers": "Run code, make HTTP requests, set webhooks, etc.",
"nodeCreator.subcategoryDescriptions.otherTriggerNodes": "Runs the flow on workflow errors, file changes, etc.",
"nodeCreator.subcategoryDescriptions.agents": "Autonomous entities that interact and make decisions.",
"nodeCreator.subcategoryDescriptions.chains": "Structured assemblies for specific tasks.",
@@ -1054,11 +1058,14 @@
"nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName": "On a schedule",
"nodeCreator.triggerHelperPanel.scheduleTriggerDescription": "Runs the flow every day, hour, or custom interval",
"nodeCreator.triggerHelperPanel.webhookTriggerDisplayName": "On webhook call",
"nodeCreator.triggerHelperPanel.webhookTriggerDescription": "Runs the flow when another app sends a webhook",
"nodeCreator.triggerHelperPanel.webhookTriggerDescription": "Runs the flow on receiving an HTTP request",
"nodeCreator.triggerHelperPanel.formTriggerDisplayName": "On form submission",
"nodeCreator.triggerHelperPanel.formTriggerDescription": "Runs the flow when an n8n generated webform is submitted",
"nodeCreator.triggerHelperPanel.manualTriggerDisplayName": "Manually",
"nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n",
"nodeCreator.triggerHelperPanel.manualTriggerDisplayName": "Trigger manually",
"nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n. Good for getting started quickly",
"nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message",
"nodeCreator.triggerHelperPanel.manualChatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
"nodeCreator.triggerHelperPanel.manualTriggerTag": "Recommended",
"nodeCreator.triggerHelperPanel.whatHappensNext": "What happens next?",
"nodeCreator.triggerHelperPanel.selectATrigger": "What triggers this workflow?",
"nodeCreator.triggerHelperPanel.selectATriggerDescription": "A trigger is a step that starts your workflow",
@@ -1072,7 +1079,8 @@
"nodeCreator.aiPanel.newTag": "New",
"nodeCreator.aiPanel.langchainAiNodes": "Advanced AI",
"nodeCreator.aiPanel.title": "When should this workflow run?",
"nodeCreator.aiPanel.infoBox": "Check out our <a href=\"{link}\" target=\"_blank\">templates</a> for workflow examples and inspiration.",
"nodeCreator.aiPanel.linkItem.description": "See what's possible and get started 5x faster",
"nodeCreator.aiPanel.linkItem.title": "AI Templates",
"nodeCreator.aiPanel.scheduleTriggerDisplayName": "On a schedule",
"nodeCreator.aiPanel.scheduleTriggerDescription": "Runs the flow every day, hour, or custom interval",
"nodeCreator.aiPanel.webhookTriggerDisplayName": "On webhook call",

View File

@@ -15,7 +15,7 @@ import {
scaleReset,
scaleSmaller,
} from '@/utils/canvasUtils';
import { START_NODE_TYPE } from '@/constants';
import { MANUAL_TRIGGER_NODE_TYPE, START_NODE_TYPE } from '@/constants';
import type {
BeforeStartEventParams,
BrowserJsPlumbInstance,
@@ -61,6 +61,9 @@ export const useCanvasStore = defineStore('canvas', () => {
(node) => node.type === START_NODE_TYPE || nodeTypesStore.isTriggerNode(node.type),
),
);
const aiNodes = computed<INodeUi[]>(() =>
nodes.value.filter((node) => node.type.includes('langchain')),
);
const isDemo = ref<boolean>(false);
const nodeViewScale = ref<number>(1);
const canvasAddButtonPosition = ref<XYPosition>([1, 1]);
@@ -91,6 +94,23 @@ export const useCanvasStore = defineStore('canvas', () => {
};
};
const getAutoAddManualTriggerNode = (): INodeUi | null => {
const manualTriggerNode = nodeTypesStore.getNodeType(MANUAL_TRIGGER_NODE_TYPE);
if (!manualTriggerNode) {
console.error('Could not find the manual trigger node');
return null;
}
return {
id: uuid(),
name: manualTriggerNode.defaults.name?.toString() ?? manualTriggerNode.displayName,
type: MANUAL_TRIGGER_NODE_TYPE,
parameters: {},
position: canvasAddButtonPosition.value,
typeVersion: 1,
};
};
const getNodesWithPlaceholderNode = (): INodeUi[] =>
triggerNodes.value.length > 0 ? nodes.value : [getPlaceholderTriggerNodeUI(), ...nodes.value];
@@ -298,6 +318,7 @@ export const useCanvasStore = defineStore('canvas', () => {
newNodeInsertPosition,
jsPlumbInstance,
isLoading: loadingService.isLoading,
aiNodes,
startLoading: loadingService.startLoading,
setLoadingText: loadingService.setLoadingText,
stopLoading: loadingService.stopLoading,
@@ -311,5 +332,6 @@ export const useCanvasStore = defineStore('canvas', () => {
zoomToFit,
wheelScroll,
initInstance,
getAutoAddManualTriggerNode,
};
});

View File

@@ -121,7 +121,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
* Constructs URLSearchParams object based on the default parameters for the template repository
* and provided additional parameters
*/
websiteTemplateRepositoryParameters() {
websiteTemplateRepositoryParameters(roleOverride?: string) {
const rootStore = useRootStore();
const userStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
@@ -133,6 +133,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
};
const userRole: string | undefined =
userStore.currentUserCloudInfo?.role ?? userStore.currentUser?.personalizationAnswers?.role;
if (userRole) {
defaultParameters.utm_user_role = userRole;
}
@@ -156,10 +157,15 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
* Construct the URL for the template category page on the website for a given category id
*/
getWebsiteCategoryURL() {
return (id: string) => {
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}/?${this.websiteTemplateRepositoryParameters({
categories: id,
}).toString()}`;
return (id?: string, roleOverride?: string) => {
const payload: Record<string, string> = {};
if (id) {
payload.categories = id;
}
if (roleOverride) {
payload.utm_user_role = roleOverride;
}
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}/?${this.websiteTemplateRepositoryParameters(payload).toString()}`;
};
},
},

View File

@@ -11,7 +11,7 @@ const MOCK_EXECUTION: Partial<IExecutionResponse> = {
startData: {},
resultData: {
runData: {
'When clicking "Test workflow"': [
'When clicking Test workflow': [
{
startTime: 1706027170005,
executionTime: 0,
@@ -24,7 +24,7 @@ const MOCK_EXECUTION: Partial<IExecutionResponse> = {
{
startTime: 1706027170005,
executionTime: 1,
source: [{ previousNode: 'When clicking "Test workflow"' }],
source: [{ previousNode: 'When clicking Test workflow' }],
executionStatus: 'success',
data: {
main: [
@@ -258,54 +258,54 @@ describe('pairedItemUtils', () => {
const actual = getPairedItemsMapping(MOCK_EXECUTION);
const expected = {
DebugHelper_r0_o0_i0: new Set([
'When clicking "Test workflow"_r0_o0_i0',
'When clicking Test workflow_r0_o0_i0',
'If_r0_o0_i0',
'Edit Fields_r1_o0_i0',
'Edit Fields1_r1_o0_i0',
]),
DebugHelper_r0_o0_i1: new Set([
'When clicking "Test workflow"_r0_o0_i0',
'When clicking Test workflow_r0_o0_i0',
'If_r0_o1_i0',
'Edit Fields_r0_o0_i0',
'Edit Fields1_r0_o0_i0',
]),
'Edit Fields1_r0_o0_i0': new Set([
'When clicking "Test workflow"_r0_o0_i0',
'When clicking Test workflow_r0_o0_i0',
'DebugHelper_r0_o0_i1',
'If_r0_o1_i0',
'Edit Fields_r0_o0_i0',
]),
'Edit Fields1_r1_o0_i0': new Set([
'When clicking "Test workflow"_r0_o0_i0',
'When clicking Test workflow_r0_o0_i0',
'DebugHelper_r0_o0_i0',
'If_r0_o0_i0',
'Edit Fields_r1_o0_i0',
]),
'Edit Fields_r0_o0_i0': new Set([
'When clicking "Test workflow"_r0_o0_i0',
'When clicking Test workflow_r0_o0_i0',
'DebugHelper_r0_o0_i1',
'If_r0_o1_i0',
'Edit Fields1_r0_o0_i0',
]),
'Edit Fields_r1_o0_i0': new Set([
'When clicking "Test workflow"_r0_o0_i0',
'When clicking Test workflow_r0_o0_i0',
'DebugHelper_r0_o0_i0',
'If_r0_o0_i0',
'Edit Fields1_r1_o0_i0',
]),
If_r0_o0_i0: new Set([
'When clicking "Test workflow"_r0_o0_i0',
'When clicking Test workflow_r0_o0_i0',
'DebugHelper_r0_o0_i0',
'Edit Fields_r1_o0_i0',
'Edit Fields1_r1_o0_i0',
]),
If_r0_o1_i0: new Set([
'When clicking "Test workflow"_r0_o0_i0',
'When clicking Test workflow_r0_o0_i0',
'DebugHelper_r0_o0_i1',
'Edit Fields_r0_o0_i0',
'Edit Fields1_r0_o0_i0',
]),
'When clicking "Test workflow"_r0_o0_i0': new Set([
'When clicking Test workflow_r0_o0_i0': new Set([
'DebugHelper_r0_o0_i0',
'DebugHelper_r0_o0_i1',
'If_r0_o0_i0',

View File

@@ -250,6 +250,7 @@ import {
UPDATE_WEBHOOK_ID_NODE_TYPES,
TIME,
AI_ASSISTANT_LOCAL_STORAGE_KEY,
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
} from '@/constants';
import useGlobalLinkActions from '@/composables/useGlobalLinkActions';
@@ -395,6 +396,7 @@ import type { ProjectSharingData } from '@/features/projects/projects.types';
import { useAIStore } from '@/stores/ai.store';
import { useStorage } from '@/composables/useStorage';
import { isJSPlumbEndpointElement } from '@/utils/typeGuards';
import { usePostHog } from '@/stores/posthog.store';
import { ProjectTypes } from '@/features/projects/projects.utils';
interface AddNodeOptions {
@@ -944,6 +946,17 @@ export default defineComponent({
action: this.openSelectiveNodeCreator,
});
this.registerCustomAction({
key: 'showNodeCreator',
action: () => {
this.ndvStore.activeNodeName = null;
void this.$nextTick(() => {
this.showTriggerCreator(NODE_CREATOR_OPEN_SOURCES.TAB);
});
},
});
this.readOnlyEnvRouteCheck();
this.canvasStore.isDemo = this.isDemo;
},
@@ -1177,12 +1190,6 @@ export default defineComponent({
? this.$locale.baseText('nodeView.addOrEnableTriggerNode')
: this.$locale.baseText('nodeView.addATriggerNodeFirst');
this.registerCustomAction({
key: 'showNodeCreator',
action: () =>
this.showTriggerCreator(NODE_CREATOR_OPEN_SOURCES.NO_TRIGGER_EXECUTION_TOOLTIP),
});
const notice = this.showMessage({
type: 'info',
title: this.$locale.baseText('nodeView.cantExecuteNoTrigger'),
@@ -1257,9 +1264,15 @@ export default defineComponent({
},
showTriggerCreator(source: NodeCreatorOpenSource) {
if (this.createNodeActive) return;
this.ndvStore.activeNodeName = null;
this.nodeCreatorStore.setSelectedView(TRIGGER_NODE_CREATOR_VIEW);
this.nodeCreatorStore.setShowScrim(true);
this.onToggleNodeCreator({ source, createNodeActive: true });
this.onToggleNodeCreator({
source,
createNodeActive: true,
nodeCreatorView: TRIGGER_NODE_CREATOR_VIEW,
});
},
async openExecution(executionId: string) {
this.canvasStore.startLoading();
@@ -3659,6 +3672,7 @@ export default defineComponent({
this.workflowsStore.workflow.scopes = scopes;
},
async newWorkflow(): Promise<void> {
const { getVariant } = usePostHog();
this.canvasStore.startLoading();
this.resetWorkspace();
this.workflowData = await this.workflowsStore.getNewWorkflowData(
@@ -3670,15 +3684,24 @@ export default defineComponent({
this.uiStore.stateIsDirty = false;
this.canvasStore.setZoomLevel(1, [0, 0]);
await this.tryToAddWelcomeSticky();
this.canvasStore.zoomToFit();
this.uiStore.nodeViewInitialized = true;
this.historyStore.reset();
this.executionsStore.activeExecution = null;
this.makeNewWorkflowShareable();
this.canvasStore.stopLoading();
},
async tryToAddWelcomeSticky(): Promise<void> {
this.canvasStore.zoomToFit();
// Pre-populate the canvas with the manual trigger node if the experiment is enabled and the user is in the variant group
if (
getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) ===
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.variant
) {
const manualTriggerNode = this.canvasStore.getAutoAddManualTriggerNode();
if (manualTriggerNode) {
await this.addNodes([manualTriggerNode]);
this.uiStore.lastSelectedNode = manualTriggerNode.name;
}
}
},
async initView(): Promise<void> {
if (this.$route.params.action === 'workflowSave') {
@@ -5375,4 +5398,3 @@ export default defineComponent({
);
}
</style>
, IRun, IPushDataExecutionFinished