feat(core): Update LLM applications building support (no-changelog) (#7710)

extracted out of #7336

---------

Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-11-28 16:47:28 +01:00 committed by GitHub
parent 4a89504d54
commit 117962d473
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1135 additions and 183 deletions

View file

@ -51,6 +51,10 @@ describe('Execution', () => {
.canvasNodeByName('Manual')
.within(() => cy.get('.fa-check'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check'))
@ -120,8 +124,8 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().click();
workflowPage.getters.clearExecutionDataButton().should('not.exist');
// Check warning toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.warningToast().should('be.visible');
// Check success toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.successToast().should('be.visible');
});
it('should test webhook workflow', () => {
@ -267,7 +271,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().click();
workflowPage.getters.clearExecutionDataButton().should('not.exist');
// Check warning toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.warningToast().should('be.visible');
// Check success toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.successToast().should('be.visible');
});
});

View file

@ -1,6 +1,6 @@
import 'cypress-real-events';
import { WorkflowPage } from '../pages';
import { BACKEND_BASE_URL, N8N_AUTH_COOKIE } from '../constants';
import { BACKEND_BASE_URL, INSTANCE_MEMBERS, INSTANCE_OWNER, N8N_AUTH_COOKIE } from '../constants';
Cypress.Commands.add('getByTestId', (selector, ...args) => {
return cy.get(`[data-test-id="${selector}"]`, ...args);
@ -169,6 +169,13 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => {
});
});
Cypress.Commands.add('push', (type, data) => {
cy.request('POST', `${BACKEND_BASE_URL}/rest/e2e/push`, {
type,
data,
});
});
Cypress.Commands.add('shouldNotHaveConsoleErrors', () => {
cy.window().then((win) => {
const spy = cy.spy(win.console, 'error');

View file

@ -39,6 +39,7 @@ declare global {
options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean },
): void;
draganddrop(draggableSelector: string, droppableSelector: string): void;
push(type: string, data: unknown): void;
shouldNotHaveConsoleErrors(): void;
}
}

View file

@ -47,8 +47,8 @@
"@types/supertest": "^2.0.12",
"@vitest/coverage-v8": "^0.33.0",
"cross-env": "^7.0.3",
"cypress-otp": "^1.0.3",
"cypress": "^12.17.2",
"cypress-otp": "^1.0.3",
"cypress-real-events": "^1.9.1",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",

View file

@ -1,5 +1,5 @@
import { Request } from 'express';
import { Service } from 'typedi';
import { Container, Service } from 'typedi';
import { v4 as uuid } from 'uuid';
import config from '@/config';
import type { Role } from '@db/entities/Role';
@ -13,8 +13,9 @@ import { License } from '@/License';
import { LICENSE_FEATURES, inE2ETests } from '@/constants';
import { NoAuthRequired, Patch, Post, RestController } from '@/decorators';
import type { UserSetupPayload } from '@/requests';
import type { BooleanLicenseFeature } from '@/Interfaces';
import type { BooleanLicenseFeature, IPushDataType } from '@/Interfaces';
import { MfaService } from '@/Mfa/mfa.service';
import { Push } from '@/push';
if (!inE2ETests) {
console.error('E2E endpoints only allowed during E2E tests');
@ -51,6 +52,16 @@ type ResetRequest = Request<
}
>;
type PushRequest = Request<
{},
{},
{
type: IPushDataType;
sessionId: string;
data: object;
}
>;
@Service()
@NoAuthRequired()
@RestController('/e2e')
@ -95,6 +106,17 @@ export class E2EController {
await this.setupUserManagement(req.body.owner, req.body.members);
}
@Post('/push')
async push(req: PushRequest) {
const pushInstance = Container.get(Push);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const sessionId = Object.keys(pushInstance.getBackend().connections as object)[0];
pushInstance.send(req.body.type, req.body.data, sessionId);
}
@Patch('/feature')
setFeature(req: Request<{}, {}, { feature: BooleanLicenseFeature; enabled: boolean }>) {
const { enabled, feature } = req.body;

View file

@ -63,6 +63,10 @@ export class Push extends EventEmitter {
this.backend.send(type, data, sessionId);
}
getBackend() {
return this.backend;
}
sendToUsers<D>(type: IPushDataType, data: D, userIds: Array<User['id']>) {
this.backend.sendToUsers(type, data, userIds);
}

View file

@ -3301,9 +3301,11 @@ export function getExecuteFunctions(
// Display on the calling node which node has the error
throw new NodeOperationError(
connectedNode,
`Error on node "${connectedNode.name}" which is connected via input "${inputName}"`,
`Error in sub-node ${connectedNode.name}`,
{
itemIndex,
functionality: 'configuration-node',
description: error.message,
},
);
}

View file

@ -9,7 +9,7 @@
:style="iconStyleData"
>
<!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it -->
<n8n-tooltip placement="top" :disabled="!showTooltip" v-if="showTooltip">
<n8n-tooltip :placement="tooltipPosition" :disabled="!showTooltip" v-if="showTooltip">
<template #content>{{ nodeTypeName }}</template>
<div v-if="type !== 'unknown'" :class="$style.icon">
<img v-if="type === 'file'" :src="src" :class="$style.nodeIconImage" />
@ -78,6 +78,10 @@ export default defineComponent({
showTooltip: {
type: Boolean,
},
tooltipPosition: {
type: String,
default: 'top',
},
badge: { type: Object as PropType<{ src: string; type: string }> },
},
computed: {

View file

@ -66,7 +66,9 @@ export default defineComponent({
},
sanitizeHtml(text: string): string {
return sanitizeHtml(text, {
allowedAttributes: { a: ['data-key', 'href', 'target'] },
allowedAttributes: {
a: ['data-key', 'href', 'target', 'data-action', 'data-action-parameter-connectiontype'],
},
});
},
onClick(event: MouseEvent) {

View file

@ -31,6 +31,13 @@
// Secondary tokens
// LangChain
--color-lm-chat-messages-background: var(--prim-gray-820);
--color-lm-chat-bot-background: var(--prim-gray-540);
--color-lm-chat-bot-border: var(--prim-gray-490);
--color-lm-chat-user-background: var(--prim-color-alt-a-shade-100);
--color-lm-chat-user-border: var(--prim-color-alt-a);
// Canvas
--color-canvas-background: var(--prim-gray-820);
--color-canvas-dot: var(--prim-gray-670);
@ -174,6 +181,11 @@
--border-color-light: var(--color-foreground-light);
--border-base: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
--node-type-supplemental-label-color-l: 100%;
--node-type-supplemental-label-color: hsl(
var(--node-type-supplemental-label-color-h),
var(--node-type-supplemental-label-color-s),
var(--node-type-supplemental-label-color-l)
);
--color-configurable-node-name: var(--color-text-lighter);
--color-secondary-link: var(--prim-color-secondary-tint-200);
--color-secondary-link-hover: var(--prim-color-secondary-tint-100);

View file

@ -64,6 +64,13 @@
// Secondary tokens
// LangChain
--color-lm-chat-messages-background: var(--color-background-base);
--color-lm-chat-bot-background: var(--prim-gray-120);
--color-lm-chat-bot-border: var(--prim-gray-200);
--color-lm-chat-user-background: var(--prim-color-alt-a-tint-400);
--color-lm-chat-user-border: var(--prim-color-alt-a-tint-300);
// Canvas
--color-canvas-background: var(--prim-gray-10);
--color-canvas-dot: var(--prim-gray-120);

View file

@ -87,6 +87,7 @@
"@faker-js/faker": "^8.0.2",
"@pinia/testing": "^0.1.3",
"@sentry/vite-plugin": "^2.5.0",
"@testing-library/vue": "^7.0.0",
"@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.1",
"@types/humanize-duration": "^3.27.1",

View file

@ -1269,6 +1269,7 @@ export type NodeCreatorOpenSource =
| 'tab'
| 'node_connection_action'
| 'node_connection_drop'
| 'notice_error_message'
| 'add_node_button';
export interface INodeCreatorState {

View file

@ -0,0 +1,47 @@
import type { INodeTypeData, INodeTypeDescription } from 'n8n-workflow';
import {
AGENT_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
} from '@/constants';
import nodeTypesJson from '../../../nodes-base/dist/types/nodes.json';
const allNodeTypes = [...nodeTypesJson];
function findNodeWithName(name: string): INodeTypeDescription {
return allNodeTypes.find((node) => node.name === name) as INodeTypeDescription;
}
export const testingNodeTypes: INodeTypeData = {
[MANUAL_TRIGGER_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeWithName(MANUAL_TRIGGER_NODE_TYPE),
},
},
[MANUAL_CHAT_TRIGGER_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeWithName(MANUAL_CHAT_TRIGGER_NODE_TYPE),
},
},
[AGENT_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeWithName(AGENT_NODE_TYPE),
},
},
};
export const defaultMockNodeTypes: INodeTypeData = {
[MANUAL_TRIGGER_NODE_TYPE]: testingNodeTypes[MANUAL_TRIGGER_NODE_TYPE],
};
export function mockNodeTypesToArray(nodeTypes: INodeTypeData): INodeTypeDescription[] {
return Object.values(nodeTypes).map(
(nodeType) => nodeType.type.description as INodeTypeDescription,
);
}
export const defaultMockNodeTypesArray: INodeTypeDescription[] =
mockNodeTypesToArray(defaultMockNodeTypes);

View file

@ -0,0 +1,111 @@
import type {
INodeType,
INodeTypeData,
INodeTypes,
IVersionedNodeType,
IConnections,
IDataObject,
INode,
IPinData,
IWorkflowSettings,
} from 'n8n-workflow';
import { NodeHelpers, Workflow } from 'n8n-workflow';
import { uuid } from '@jsplumb/util';
import { defaultMockNodeTypes } from '@/__tests__/defaults';
import type {
INodeUi,
ITag,
IUsedCredential,
IUser,
IWorkflowDb,
WorkflowMetadata,
} from '@/Interface';
export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
const getResolvedKey = (key: string) => {
const resolvedKeyParts = key.split(/[\/.]/);
return resolvedKeyParts[resolvedKeyParts.length - 1];
};
const nodeTypes = {
...defaultMockNodeTypes,
...Object.keys(data).reduce<INodeTypeData>((acc, key) => {
acc[getResolvedKey(key)] = data[key];
return acc;
}, {}),
};
function getByName(nodeType: string): INodeType | IVersionedNodeType {
return nodeTypes[getResolvedKey(nodeType)].type;
}
function getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedNodeType(getByName(nodeType), version);
}
return {
getByName,
getByNameAndVersion,
};
}
export function createTestWorkflowObject(options: {
id?: string;
name?: string;
nodes: INode[];
connections: IConnections;
active?: boolean;
nodeTypes?: INodeTypeData;
staticData?: IDataObject;
settings?: IWorkflowSettings;
pinData?: IPinData;
}) {
return new Workflow({
...options,
id: options.id ?? uuid(),
active: options.active ?? false,
nodeTypes: createTestNodeTypes(options.nodeTypes),
connections: options.connections ?? {},
});
}
export function createTestWorkflow(options: {
id?: string;
name: string;
active?: boolean;
createdAt?: number | string;
updatedAt?: number | string;
nodes?: INodeUi[];
connections?: IConnections;
settings?: IWorkflowSettings;
tags?: ITag[] | string[];
pinData?: IPinData;
sharedWith?: Array<Partial<IUser>>;
ownedBy?: Partial<IUser>;
versionId?: string;
usedCredentials?: IUsedCredential[];
meta?: WorkflowMetadata;
}): IWorkflowDb {
return {
...options,
createdAt: options.createdAt ?? '',
updatedAt: options.updatedAt ?? '',
versionId: options.versionId ?? '',
id: options.id ?? uuid(),
active: options.active ?? false,
connections: options.connections ?? {},
} as IWorkflowDb;
}
export function createTestNode(
node: Partial<INode> & { name: INode['name']; type: INode['type'] },
): INode {
return {
id: uuid(),
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
...node,
};
}

View file

@ -41,6 +41,7 @@ const defaultSettings: IN8nUISettings = {
oauthCallbackUrls: { oauth1: '', oauth2: '' },
onboardingCallPromptEnabled: false,
personalizationSurveyEnabled: false,
releaseChannel: 'stable',
posthog: {
apiHost: '',
apiKey: '',

View file

@ -5,7 +5,6 @@ import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
import Modal from './Modal.vue';
import { CHAT_EMBED_MODAL_KEY, WEBHOOK_NODE_TYPE } from '../constants';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
@ -22,7 +21,6 @@ const props = defineProps({
const i18n = useI18n();
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const tabs = ref([
{

View file

@ -1,12 +1,11 @@
import Vue from 'vue';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { defineComponent } from 'vue';
const escape = (str: string) => str.replace('$', '\\$');
export const secretsCompletions = (Vue as CodeNodeEditorMixin).extend({
export const secretsCompletions = defineComponent({
methods: {
/**
* Complete `$secrets.` to `$secrets.providerName` and `$secrets.providerName.secretName`.

View file

@ -1,7 +1,7 @@
<template>
<div>
<div class="error-header">
<div class="error-message">{{ getErrorMessage() }}</div>
<div class="error-message" v-text="getErrorMessage()" />
<div class="error-description" v-if="error.description" v-html="getErrorDescription()"></div>
</div>
<details>
@ -125,7 +125,12 @@ import { copyPaste } from '@/mixins/copyPaste';
import { useToast } from '@/composables/useToast';
import { MAX_DISPLAY_DATA_SIZE } from '@/constants';
import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow';
import type {
INodeProperties,
INodePropertyCollection,
INodePropertyOptions,
NodeOperationError,
} from 'n8n-workflow';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -170,6 +175,18 @@ export default defineComponent({
.replace(/%%PARAMETER_FULL%%/g, parameterFullName);
},
getErrorDescription(): string {
const isSubNodeError =
this.error.name === 'NodeOperationError' &&
(this.error as NodeOperationError).functionality === 'configuration-node';
if (isSubNodeError) {
return sanitizeHtml(
this.error.description +
this.$locale.baseText('pushConnection.executionError.openNode', {
interpolate: { node: this.error.node.name },
}),
);
}
if (!this.error.context?.descriptionTemplate) {
return sanitizeHtml(this.error.description);
}
@ -182,6 +199,20 @@ export default defineComponent({
getErrorMessage(): string {
const baseErrorMessage = this.$locale.baseText('nodeErrorView.error') + ': ';
const isSubNodeError =
this.error.name === 'NodeOperationError' &&
(this.error as NodeOperationError).functionality === 'configuration-node';
if (isSubNodeError) {
const baseErrorMessageSubNode = this.$locale.baseText('nodeErrorView.errorSubNode', {
interpolate: { node: this.error.node.name },
});
return baseErrorMessageSubNode;
}
if (this.error.message === this.error.description) {
return baseErrorMessage;
}
if (!this.error.context?.messageTemplate) {
return baseErrorMessage + this.error.message;
}

View file

@ -166,7 +166,13 @@ import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import type { INodeUi } from '@/Interface';
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
import type { ConnectionTypes, IConnectedNode, INodeTypeDescription, Workflow } from 'n8n-workflow';
import type {
ConnectionTypes,
IConnectedNode,
INodeOutputConfiguration,
INodeTypeDescription,
Workflow,
} from 'n8n-workflow';
import RunData from './RunData.vue';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import NodeExecuteButton from './NodeExecuteButton.vue';
@ -271,9 +277,9 @@ export default defineComponent({
}
if (
(inputs.length === 0 ||
inputs.find((inputName) => inputName !== NodeConnectionType.Main)) &&
outputs.find((outputName) => outputName !== NodeConnectionType.Main)
inputs.length === 0 ||
(inputs.every((input) => this.filterOutConnectionType(input, NodeConnectionType.Main)) &&
outputs.find((output) => this.filterOutConnectionType(output, NodeConnectionType.Main)))
) {
return true;
}
@ -384,6 +390,14 @@ export default defineComponent({
},
},
methods: {
filterOutConnectionType(
item: ConnectionTypes | INodeOutputConfiguration,
type: ConnectionTypes,
) {
if (!item) return false;
return typeof item === 'string' ? item !== type : item.type !== type;
},
onInputModeChange(val: MappingMode) {
this.inputMode = val;
},

View file

@ -1,5 +1,11 @@
<template>
<div>
<NDVFloatingNodes
v-if="activeNode"
@switchSelectedNode="onSwitchSelectedNode"
:root-node="activeNode"
type="input"
/>
<div :class="$style.inputPanel" v-if="!hideInputAndOutput" :style="inputPanelStyles">
<slot name="input"></slot>
</div>
@ -50,6 +56,7 @@ import { LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH, MAIN_NODE_PANEL_WIDTH } from '
import { debounceHelper } from '@/mixins/debounce';
import { useNDVStore } from '@/stores/ndv.store';
import { ndvEventBus } from '@/event-bus';
import NDVFloatingNodes from '@/components/NDVFloatingNodes.vue';
const SIDE_MARGIN = 24;
const SIDE_PANELS_MARGIN = 80;
@ -70,6 +77,7 @@ export default defineComponent({
mixins: [debounceHelper],
components: {
PanelDragButton,
NDVFloatingNodes,
},
props: {
isDraggable: {
@ -136,6 +144,9 @@ export default defineComponent({
} {
return this.ndvStore.getMainPanelDimensions(this.currentNodePaneType);
},
activeNode() {
return this.ndvStore.activeNode;
},
supportedResizeDirections(): string[] {
const supportedDirections = ['right'];
@ -249,6 +260,9 @@ export default defineComponent({
},
},
methods: {
onSwitchSelectedNode(node: string) {
this.$emit('switchSelectedNode', node);
},
getInitialLeftPosition(width: number) {
if (this.currentNodePaneType === 'dragless')
return this.pxToRelativeWidth(SIDE_MARGIN + 1 + this.fixedPanelWidth);

View file

@ -0,0 +1,262 @@
<template>
<aside :class="$style.floatingNodes">
<ul
v-for="connectionGroup in connectionGroups"
:class="[$style.nodesList, $style[connectionGroup]]"
:key="connectionGroup"
>
<template v-for="{ node, nodeType } in connectedNodes[connectionGroup]">
<n8n-tooltip
:placement="tooltipPositionMapper[connectionGroup]"
v-if="node && nodeType"
:teleported="false"
:key="node.name"
:offset="60"
>
<template #content>{{ node.name }}</template>
<li
:class="$style.connectedNode"
@click="$emit('switchSelectedNode', node.name)"
data-test-id="floating-node"
:data-node-name="node.name"
:data-node-placement="connectionGroup"
>
<node-icon
:nodeType="nodeType"
:nodeName="node.name"
:tooltip-position="tooltipPositionMapper[connectionGroup]"
:size="35"
circle
/>
</li>
</n8n-tooltip>
</template>
</ul>
</aside>
</template>
<script setup lang="ts">
import type { INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed, onMounted, onBeforeUnmount } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
import type { INodeTypeDescription } from 'n8n-workflow';
interface Props {
rootNode: INodeUi;
type: 'input' | 'sub-input' | 'sub-output' | 'output';
}
const enum FloatingNodePosition {
top = 'outputSub',
right = 'outputMain',
bottom = 'inputSub',
left = 'inputMain',
}
const props = defineProps<Props>();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const workflow = workflowsStore.getCurrentWorkflow();
const emit = defineEmits(['switchSelectedNode']);
interface NodeConfig {
node: INodeUi;
nodeType: INodeTypeDescription;
}
function moveNodeDirection(direction: FloatingNodePosition) {
const matchedDirectionNode = connectedNodes.value[direction][0];
if (matchedDirectionNode) {
emit('switchSelectedNode', matchedDirectionNode.node.name);
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.shiftKey && e.altKey && (e.ctrlKey || e.metaKey)) {
/* eslint-disable @typescript-eslint/naming-convention */
const mapper = {
ArrowUp: FloatingNodePosition.top,
ArrowRight: FloatingNodePosition.right,
ArrowDown: FloatingNodePosition.bottom,
ArrowLeft: FloatingNodePosition.left,
};
/* eslint-enable @typescript-eslint/naming-convention */
const matchingDirection = mapper[e.key as keyof typeof mapper] || null;
if (matchingDirection) {
moveNodeDirection(matchingDirection);
}
}
}
function getINodesFromNames(names: string[]): NodeConfig[] {
return names
.map((name) => {
const node = workflowsStore.getNodeByName(name);
if (node) {
const nodeType = nodeTypesStore.getNodeType(node.type);
if (nodeType) {
return { node, nodeType };
}
}
return null;
})
.filter((n): n is NodeConfig => n !== null);
}
const connectedNodes = computed<
Record<FloatingNodePosition, Array<{ node: INodeUi; nodeType: INodeTypeDescription }>>
>(() => {
const rootName = props.rootNode.name;
return {
[FloatingNodePosition.top]: getINodesFromNames(
workflow.getChildNodes(rootName, 'ALL_NON_MAIN'),
),
[FloatingNodePosition.right]: getINodesFromNames(workflow.getChildNodes(rootName, 'main', 1)),
[FloatingNodePosition.bottom]: getINodesFromNames(
workflow.getParentNodes(rootName, 'ALL_NON_MAIN'),
),
[FloatingNodePosition.left]: getINodesFromNames(workflow.getParentNodes(rootName, 'main', 1)),
};
});
const connectionGroups = [
FloatingNodePosition.top,
FloatingNodePosition.right,
FloatingNodePosition.bottom,
FloatingNodePosition.left,
];
const tooltipPositionMapper = {
[FloatingNodePosition.top]: 'bottom',
[FloatingNodePosition.right]: 'left',
[FloatingNodePosition.bottom]: 'top',
[FloatingNodePosition.left]: 'right',
};
onMounted(() => {
document.addEventListener('keydown', onKeyDown, true);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown, true);
});
defineExpose({
moveNodeDirection,
});
</script>
<style lang="scss" module>
.floatingNodes {
position: fixed;
bottom: 0;
top: 0;
right: 0;
left: 0;
z-index: 10;
pointer-events: none;
}
.floatingNodes {
right: 0;
}
.nodesList {
list-style: none;
padding: 0;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
width: min-content;
margin: auto;
transform-origin: center;
gap: var(--spacing-s);
transition: transform 0.2s ease-in-out;
&.inputSub,
&.outputSub {
right: 0;
left: 0;
flex-direction: row;
}
&.outputSub {
top: 0;
}
&.inputSub {
bottom: 0;
}
&.outputMain,
&.inputMain {
top: 0;
bottom: 0;
}
&.outputMain {
right: 0;
}
&.inputMain {
left: 0;
}
&.outputMain {
transform: translateX(50%);
}
&.outputSub {
transform: translateY(-50%);
}
&.inputMain {
transform: translateX(-50%);
}
&.inputSub {
transform: translateY(50%);
}
}
.connectedNode {
border: var(--border-base);
background-color: var(--color-canvas-node-background);
border-radius: 100%;
padding: var(--spacing-s);
cursor: pointer;
pointer-events: all;
transition: transform 0.2s cubic-bezier(0.19, 1, 0.22, 1);
position: relative;
transform: scale(0.8);
display: flex;
justify-self: center;
align-self: center;
&::after {
content: '';
position: absolute;
top: -35%;
right: -30%;
bottom: -35%;
left: -30%;
z-index: -1;
}
.outputMain &,
.inputMain & {
border-radius: var(--border-radius-large);
display: flex;
align-items: center;
justify-content: center;
}
.outputMain & {
&:hover {
transform: scale(1.2) translateX(-50%);
}
}
.outputSub & {
&:hover {
transform: scale(1.2) translateY(50%);
}
}
.inputMain & {
&:hover {
transform: scale(1.2) translateX(50%);
}
}
.inputSub & {
&:hover {
transform: scale(1.2) translateY(-50%);
}
}
}
</style>

View file

@ -169,6 +169,7 @@ import type {
INodeOutputConfiguration,
INodeTypeDescription,
ITaskData,
NodeOperationError,
} from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
@ -392,7 +393,7 @@ export default defineComponent({
nodeExecutionStatus(): string {
const nodeExecutionRunData = this.workflowsStore.getWorkflowRunData?.[this.name];
if (nodeExecutionRunData) {
return nodeExecutionRunData[0].executionStatus ?? '';
return nodeExecutionRunData.filter(Boolean)[0].executionStatus ?? '';
}
return '';
},
@ -401,7 +402,7 @@ export default defineComponent({
const nodeExecutionRunData = this.workflowsStore.getWorkflowRunData?.[this.name];
if (nodeExecutionRunData) {
nodeExecutionRunData.forEach((executionRunData) => {
if (executionRunData.error) {
if (executionRunData?.error) {
issues.push(
`${executionRunData.error.message}${
executionRunData.error.description ? ` (${executionRunData.error.description})` : ''
@ -426,7 +427,10 @@ export default defineComponent({
return this.node ? this.node.position : [0, 0];
},
showDisabledLinethrough(): boolean {
return !!(this.data.disabled && this.inputs.length === 1 && this.outputs.length === 1);
return (
!this.isConfigurableNode &&
!!(this.data.disabled && this.inputs.length === 1 && this.outputs.length === 1)
);
},
shortNodeType(): string {
return this.$locale.shortNodeType(this.data.type);
@ -482,9 +486,15 @@ export default defineComponent({
borderColor = '--color-foreground-base';
} else if (!this.isExecuting) {
if (this.hasIssues) {
// Do not set red border if there is an issue with the configuration node
if (
(this.nodeRunData?.[0]?.error as NodeOperationError)?.functionality !==
'configuration-node'
) {
borderColor = '--color-danger';
returnStyles['border-width'] = '2px';
returnStyles['border-style'] = 'solid';
}
} else if (this.waiting || this.showPinnedDataInfo) {
borderColor = '--color-canvas-node-pinned-border';
} else if (this.nodeExecutionStatus === 'unknown') {
@ -608,6 +618,7 @@ export default defineComponent({
!this.isTriggerNode ||
this.isManualTypeNode ||
this.isScheduledGroup ||
this.uiStore.isModalActive ||
dataItemsCount === 0
)
return;
@ -1333,6 +1344,10 @@ export default defineComponent({
z-index: 10;
}
&.add-input-endpoint-error {
--endpoint-svg-color: var(--color-danger);
}
.add-input-endpoint-default {
transition: transform var(--add-input-endpoint--transition-duration) ease;
}

View file

@ -162,7 +162,7 @@ function onBackButton() {
v-if="activeViewStack.info && !activeViewStack.search"
:class="$style.info"
:content="activeViewStack.info"
theme="info"
theme="warning"
/>
<!-- Actions mode -->
<ActionsRenderer v-if="isActionsMode && activeViewStack.subcategory" v-bind="$attrs" />

View file

@ -8,6 +8,7 @@ import type {
SimplifiedNodeType,
} from '@/Interface';
import {
AI_CODE_NODE_TYPE,
AI_OTHERS_NODE_CREATOR_VIEW,
DEFAULT_SUBCATEGORY,
TRIGGER_NODE_CREATOR_VIEW,
@ -152,6 +153,9 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
},
panelClass: relatedAIView?.properties.panelClass,
baseFilter: (i: INodeCreateElement) => {
// AI Code node could have any connection type so we don't want to display it
// in the compatible connection view as it would be displayed in all of them
if (i.key === AI_CODE_NODE_TYPE) return false;
const displayNode = nodesByConnectionType[connectionType].includes(i.key);
// TODO: Filtering works currently fine for displaying compatible node when dropping

View file

@ -317,18 +317,6 @@ export function TriggerView(nodes: SimplifiedNodeType[]) {
],
};
const hasAINodes = (nodes ?? []).some((node) => node.codex?.categories?.includes(AI_SUBCATEGORY));
if (hasAINodes)
view.items.push({
key: AI_NODE_CREATOR_VIEW,
type: 'view',
properties: {
title: i18n.baseText('nodeCreator.aiPanel.langchainAiNodes'),
icon: 'robot',
description: i18n.baseText('nodeCreator.aiPanel.nodesForAi'),
},
});
return view;
}

View file

@ -8,6 +8,7 @@
width="auto"
append-to-body
data-test-id="ndv"
:data-has-output-connection="hasOutputConnection"
>
<n8n-tooltip
placement="bottom-start"
@ -42,6 +43,8 @@
:isDraggable="!isTriggerNode"
:hasDoubleWidth="activeNodeType?.parameterPane === 'wide'"
:nodeType="activeNodeType"
:key="activeNode.name"
@switchSelectedNode="onSwitchSelectedNode"
@close="close"
@init="onPanelsInit"
@dragstart="onDragStart"
@ -275,6 +278,15 @@ export default defineComponent({
workflow(): Workflow {
return this.getCurrentWorkflow();
},
hasOutputConnection() {
if (!this.activeNode) return false;
const outgoingConnections = this.workflowsStore.outgoingConnectionsByNodeName(
this.activeNode.name,
) as INodeConnections;
// Check if there's at-least one output connection
return (Object.values(outgoingConnections)?.[0]?.[0] ?? []).length > 0;
},
parentNodes(): string[] {
if (this.activeNode) {
return (
@ -634,6 +646,9 @@ export default defineComponent({
nodeTypeSelected(nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
async onSwitchSelectedNode(nodeTypeName: string) {
this.$emit('switchSelectedNode', nodeTypeName);
},
async close() {
if (this.isDragging) {
return;
@ -739,6 +754,10 @@ export default defineComponent({
</script>
<style lang="scss">
// Hide notice(.ndv-connection-hint-notice) warning when node has output connection
[data-has-output-connection='true'] .ndv-connection-hint-notice {
display: none;
}
.ndv-wrapper {
overflow: visible;
margin-top: 0;

View file

@ -7,8 +7,9 @@
:disabled="disabled"
:size="size"
:circle="circle"
:nodeTypeName="nodeType ? nodeType.displayName : ''"
:nodeTypeName="nodeName ?? nodeType?.displayName ?? ''"
:showTooltip="showTooltip"
:tooltipPosition="tooltipPosition"
:badge="badge"
@click="(e) => $emit('click')"
></n8n-node-icon>
@ -53,6 +54,14 @@ export default defineComponent({
type: Boolean,
default: false,
},
tooltipPosition: {
type: String,
default: 'top',
},
nodeName: {
type: String,
required: false,
},
},
computed: {
...mapStores(useRootStore),

View file

@ -65,6 +65,10 @@
$locale.baseText('ndv.output.waitingToRun')
}}</n8n-text>
<n8n-text v-if="!workflowRunning" data-test-id="ndv-output-run-node-hint">
<template v-if="isSubNode">
{{ $locale.baseText('ndv.output.runNodeHintSubNode') }}
</template>
<template v-else>
{{ $locale.baseText('ndv.output.runNodeHint') }}
<span @click="insertTestData" v-if="canPinData">
<br />
@ -73,6 +77,7 @@
{{ $locale.baseText('ndv.output.insertTestData') }}
</n8n-text>
</span>
</template>
</n8n-text>
</template>

View file

@ -30,7 +30,7 @@
<n8n-notice
v-else-if="parameter.type === 'notice'"
class="parameter-item"
:class="['parameter-item', parameter.typeOptions?.containerClass ?? '']"
:content="$locale.nodeText().inputLabelDisplayName(parameter, path)"
@action="onNoticeAction"
/>

View file

@ -1325,7 +1325,7 @@ export default defineComponent({
return 0;
}
if (this.workflowRunData?.[this.node.name][runIndex].hasOwnProperty('error')) {
if (this.workflowRunData?.[this.node.name]?.[runIndex]?.hasOwnProperty('error')) {
return 1;
}

View file

@ -265,5 +265,6 @@ onMounted(() => {
border: none;
background: none;
padding: 0;
color: var(--color-text-base);
}
</style>

View file

@ -8,6 +8,7 @@
:indent="12"
@node-click="onItemClick"
:expand-on-click-node="false"
data-test-id="lm-chat-logs-tree"
>
<template #default="{ node, data }">
<div
@ -50,7 +51,11 @@
}}
</n8n-text>
</div>
<div v-for="(data, index) in selectedRun" :key="`${data.node}__${data.runIndex}__index`">
<div
v-for="(data, index) in selectedRun"
:key="`${data.node}__${data.runIndex}__index`"
data-test-id="lm-chat-logs-entry"
>
<RunDataAiContent :inputData="data" :contentIndex="index" />
</div>
</div>

View file

@ -6,7 +6,7 @@ interface MemoryMessage {
type: string;
id: string[];
kwargs: {
content: string;
content: unknown;
additional_kwargs: Record<string, unknown>;
};
}
@ -81,6 +81,7 @@ const outputTypeParsers: {
};
},
[NodeConnectionType.AiTool]: fallbackParser,
[NodeConnectionType.AiAgent]: fallbackParser,
[NodeConnectionType.AiMemory](execData: IDataObject) {
const chatHistory =
execData.chatHistory ?? execData.messages ?? execData?.response?.chat_history;
@ -88,7 +89,23 @@ const outputTypeParsers: {
const responseText = chatHistory
.map((content: MemoryMessage) => {
if (content.type === 'constructor' && content.id?.includes('schema') && content.kwargs) {
interface MessageContent {
type: string;
image_url?: {
url: string;
};
}
let message = content.kwargs.content;
if (Array.isArray(message)) {
const messageContent = message[0] as {
type?: string;
image_url?: { url: string };
};
if (messageContent?.type === 'image_url') {
message = `![Input image](${messageContent.image_url?.url})`;
}
message = message as MessageContent[];
}
if (Object.keys(content.kwargs.additional_kwargs).length) {
message += ` (${JSON.stringify(content.kwargs.additional_kwargs)})`;
}
@ -120,7 +137,6 @@ const outputTypeParsers: {
},
[NodeConnectionType.AiOutputParser]: fallbackParser,
[NodeConnectionType.AiRetriever]: fallbackParser,
[NodeConnectionType.AiVectorRetriever]: fallbackParser,
[NodeConnectionType.AiVectorStore](execData: IDataObject) {
if (execData.documents) {
return {
@ -189,9 +205,17 @@ export const useAiContentParsers = () => {
});
const parser = outputTypeParsers[endpointType as AllowedEndpointType];
if (!parser) return [{ raw: contentJson, parsedContent: null }];
if (!parser)
return [
{
raw: contentJson.filter((item): item is IDataObject => item !== undefined),
parsedContent: null,
},
];
const parsedOutput = contentJson.map((c) => ({ raw: c, parsedContent: parser(c) }));
const parsedOutput = contentJson
.filter((c): c is IDataObject => c !== undefined)
.map((c) => ({ raw: c, parsedContent: parser(c) }));
return parsedOutput;
};

View file

@ -1,4 +1,4 @@
import { within } from '@testing-library/dom';
import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createPinia, setActivePinia } from 'pinia';
import { faker } from '@faker-js/faker';

View file

@ -64,17 +64,12 @@
</div>
</div>
</div>
<div class="logs-wrapper">
<div v-if="node" class="logs-wrapper" data-test-id="lm-chat-logs">
<n8n-text class="logs-title" tag="p" size="large">{{
$locale.baseText('chat.window.logs')
}}</n8n-text>
<div class="logs">
<run-data-ai v-if="node" :node="node" hide-title slim :key="messages.length" />
<div v-else class="no-node-connected">
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{
$locale.baseText('chat.window.noExecution')
}}</n8n-text>
</div>
<run-data-ai :node="node" hide-title slim :key="messages.length" />
</div>
</div>
</div>
@ -87,6 +82,7 @@
type="textarea"
ref="inputField"
:placeholder="$locale.baseText('chat.window.chat.placeholder')"
data-test-id="workflow-chat-input"
@keydown.stop="updated"
/>
<n8n-button
@ -97,7 +93,7 @@
size="large"
icon="comment"
type="primary"
data-test-id="workflow-chat-button"
data-test-id="workflow-chat-send-button"
/>
<n8n-info-tip class="mt-s">
@ -290,8 +286,10 @@ export default defineComponent({
if (!chatNode) {
this.showError(
new Error('Chat viable node(Agent or Chain) could not be found!'),
'Chat node not found',
new Error(
'Chat only works when an AI agent or chain is connected to the chat trigger node',
),
'Missing AI node',
);
return;
}
@ -395,6 +393,8 @@ export default defineComponent({
return;
}
const inputKey = triggerNode.typeVersion < 1.1 ? 'input' : 'chat_input';
const nodeData: ITaskData = {
startTime: new Date().getTime(),
executionTime: 0,
@ -404,7 +404,7 @@ export default defineComponent({
[
{
json: {
input: message,
[inputKey]: message,
},
},
],
@ -504,9 +504,11 @@ export default defineComponent({
display: flex;
height: 100%;
min-height: 400px;
z-index: 9999;
.logs-wrapper {
border: 1px solid #e0e0e0;
--node-icon-color: var(--color-text-base);
border: 1px solid var(--color-foreground-base);
border-radius: 4px;
height: 100%;
overflow-y: auto;
@ -518,8 +520,8 @@ export default defineComponent({
}
}
.messages {
background-color: var(--color-background-base);
border: 1px solid #e0e0e0;
background-color: var(--color-lm-chat-messages-background);
border: 1px solid var(--color-foreground-base);
border-radius: 4px;
height: 100%;
width: 100%;
@ -533,16 +535,17 @@ export default defineComponent({
width: 100%;
.content {
border-radius: 10px;
border-radius: var(--border-radius-large);
line-height: 1.5;
margin: 0.5em 1em;
margin: var(--spacing-2xs) var(--spacing-s);
max-width: 75%;
padding: 1em;
white-space: pre-wrap;
overflow-x: auto;
&.bot {
background-color: #e0d0d0;
background-color: var(--color-lm-chat-bot-background);
border: 1px solid var(--color-lm-chat-bot-border);
float: left;
.message-options {
@ -551,7 +554,8 @@ export default defineComponent({
}
&.user {
background-color: #d0e0d0;
background-color: var(--color-lm-chat-user-background);
border: 1px solid var(--color-lm-chat-user-border);
float: right;
text-align: right;

View file

@ -0,0 +1,45 @@
import ChatEmbedModal from '@/components/ChatEmbedModal.vue';
import { createTestingPinia } from '@pinia/testing';
import { CHAT_EMBED_MODAL_KEY, STORES, WEBHOOK_NODE_TYPE } from '@/constants';
import { createComponentRenderer } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue';
const renderComponent = createComponentRenderer(ChatEmbedModal, {
props: {
teleported: false,
appendToBody: false,
},
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modals: {
[CHAT_EMBED_MODAL_KEY]: { open: true },
},
},
[STORES.WORKFLOWS]: {
workflow: {
nodes: [{ type: WEBHOOK_NODE_TYPE }],
},
},
},
}),
});
describe('ChatEmbedModal', () => {
it('should render correctly', async () => {
const wrapper = renderComponent();
await waitFor(() =>
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
const tabs = wrapper.container.querySelectorAll('.n8n-tabs .tab');
const activeTab = wrapper.container.querySelector('.n8n-tabs .tab.activeTab');
const editor = wrapper.container.querySelector('.cm-editor');
expect(tabs).toHaveLength(4);
expect(activeTab).toBeVisible();
expect(activeTab).toHaveTextContent('CDN Embed');
expect(editor).toBeVisible();
});
});

View file

@ -0,0 +1,87 @@
import NodeDetailsView from '@/components/NodeDetailsView.vue';
import { VIEWS } from '@/constants';
import { createComponentRenderer } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue';
import { uuid } from '@jsplumb/util';
import type { INode } from 'n8n-workflow';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createPinia, setActivePinia } from 'pinia';
import { defaultMockNodeTypesArray } from '@/__tests__/defaults';
import { setupServer } from '@/__tests__/server';
async function createPiniaWithActiveNode(node: INode) {
const workflowId = uuid();
const workflow = createTestWorkflow({
id: workflowId,
name: 'Test Workflow',
connections: {},
active: true,
nodes: [node],
});
const pinia = createPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
nodeTypesStore.setNodeTypes(defaultMockNodeTypesArray);
workflowsStore.workflow = workflow;
ndvStore.activeNodeName = node.name;
await useSettingsStore().getSettings();
await useUsersStore().loginWithCookie();
return pinia;
}
const renderComponent = createComponentRenderer(NodeDetailsView, {
props: {
teleported: false,
appendToBody: false,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
describe('NodeDetailsView', () => {
let server: ReturnType<typeof setupServer>;
beforeAll(() => {
server = setupServer();
});
afterEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
server.shutdown();
});
it('should render correctly', async () => {
const wrapper = renderComponent({
pinia: await createPiniaWithActiveNode(
createTestNode({
name: 'Manual Trigger',
type: 'manualTrigger',
}),
),
});
await waitFor(() =>
expect(wrapper.container.querySelector('.ndv-wrapper')).toBeInTheDocument(),
);
});
});

View file

@ -17,6 +17,18 @@ export default () => {
const { [key]: _, ...rest } = state.customActions;
state.customActions = rest;
}
function getElementAttributes(element: Element) {
const attributesObject: Record<string, string> = {};
for (let i = 0; i < element.attributes.length; i++) {
const attr = element.attributes[i];
if (attr.name.startsWith('data-action-parameter-')) {
attributesObject[attr.name.replace('data-action-parameter-', '')] = attr.value;
}
}
return attributesObject;
}
function delegateClick(e: MouseEvent) {
const clickedElement = e.target;
if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return;
@ -24,7 +36,9 @@ export default () => {
const actionAttribute = clickedElement.getAttribute('data-action');
if (actionAttribute && typeof availableActions.value[actionAttribute] === 'function') {
e.preventDefault();
availableActions.value[actionAttribute]();
// Extract and parse `data-action-parameter-` attributes and pass them to the action
const elementAttributes = getElementAttributes(clickedElement);
availableActions.value[actionAttribute](elementAttributes);
}
}

View file

@ -126,6 +126,7 @@ export const JIRA_TRIGGER_NODE_TYPE = 'n8n-nodes-base.jiraTrigger';
export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel';
export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
export const MANUAL_CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.manualChatTrigger';
export const AGENT_NODE_TYPE = '@n8n/n8n-nodes-langchain.agent';
export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams';
export const N8N_NODE_TYPE = 'n8n-nodes-base.n8n';
export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
@ -194,6 +195,7 @@ export const NODE_CREATOR_OPEN_SOURCES: Record<
TAB: 'tab',
NODE_CONNECTION_ACTION: 'node_connection_action',
NODE_CONNECTION_DROP: 'node_connection_drop',
NOTICE_ERROR_MESSAGE: 'notice_error_message',
CONTEXT_MENU: 'context_menu',
'': '',
};

View file

@ -176,6 +176,8 @@ export const nodeHelpers = defineComponent({
}
for (const taskData of workflowResultData[node.name]) {
if (!taskData) return false;
if (taskData.error !== undefined) {
return true;
}
@ -313,11 +315,24 @@ export const nodeHelpers = defineComponent({
const parentNodes = workflow.getParentNodes(node.name, input.type, 1);
if (parentNodes.length === 0) {
// We want to show different error for missing AI subnodes
if (input.type.startsWith('ai_')) {
foundIssues[input.type] = [
this.$locale.baseText('nodeIssues.input.missing', {
interpolate: { inputName: input.displayName || input.type },
this.$locale.baseText('nodeIssues.input.missingSubNode', {
interpolate: {
inputName: input.displayName?.toLocaleLowerCase() ?? input.type,
inputType: input.type,
node: node.name,
},
}),
];
} else {
foundIssues[input.type] = [
this.$locale.baseText('nodeIssues.input.missing', {
interpolate: { inputName: input.displayName ?? input.type },
}),
];
}
}
});

View file

@ -11,8 +11,8 @@ import {
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { NodeConnectionType, NodeHelpers, jsonParse, jsonStringify } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { jsonParse, jsonStringify } from 'n8n-workflow';
export type PinDataSource =
| 'pin-icon-click'
@ -39,9 +39,19 @@ export const pinData = defineComponent({
hasPinData(): boolean {
return !!this.node && typeof this.pinData !== 'undefined';
},
isSubNode() {
if (!this.nodeType.outputs || typeof this.nodeType.outputs === 'string') {
return false;
}
const outputTypes = NodeHelpers.getConnectionTypes(this.nodeType.outputs);
return outputTypes
? outputTypes.filter((output) => output !== NodeConnectionType.Main).length > 0
: false;
},
isPinDataNodeType(): boolean {
return (
!!this.node &&
!this.isSubNode &&
!this.isMultipleOutputsNodeType &&
!PIN_DATA_NODE_TYPES_DENYLIST.includes(this.node.type)
);

View file

@ -20,6 +20,7 @@ import type {
IWorkflowBase,
SubworkflowOperationError,
IExecuteContextData,
NodeOperationError,
} from 'n8n-workflow';
import { TelemetryHelpers } from 'n8n-workflow';
@ -396,13 +397,46 @@ export const pushConnection = defineComponent({
type: 'error',
duration: 0,
});
} else if (
runDataExecuted.data.resultData.error?.name === 'NodeOperationError' &&
(runDataExecuted.data.resultData.error as NodeOperationError).functionality ===
'configuration-node'
) {
// If the error is a configuration error of the node itself doesn't get executed so we can't use lastNodeExecuted for the title
let title: string;
const nodeError = runDataExecuted.data.resultData.error as NodeOperationError;
if (nodeError.node.name) {
title = `Error in sub-node ${nodeError.node.name}`;
} else {
title = 'Problem executing workflow';
}
this.showMessage({
title,
message:
(nodeError?.description ?? runDataExecutedErrorMessage) +
this.$locale.baseText('pushConnection.executionError.openNode', {
interpolate: {
node: nodeError.node.name,
},
}),
type: 'error',
duration: 0,
dangerouslyUseHTMLString: true,
});
} else {
let title: string;
let type = 'error';
if (runDataExecuted.status === 'canceled') {
title = this.$locale.baseText('nodeView.showMessage.stopExecutionTry.title');
type = 'warning';
} else if (runDataExecuted.data.resultData.lastNodeExecuted) {
const isManualExecutionCancelled =
runDataExecuted.mode === 'manual' && runDataExecuted.status === 'canceled';
// Do not show the error message if the workflow got canceled manually
if (isManualExecutionCancelled) {
this.showMessage({
title: this.$locale.baseText('nodeView.showMessage.stopExecutionTry.title'),
type: 'success',
});
} else {
if (runDataExecuted.data.resultData.lastNodeExecuted) {
title = `Problem in node ${runDataExecuted.data.resultData.lastNodeExecuted}`;
} else {
title = 'Problem executing workflow';
@ -411,11 +445,12 @@ export const pushConnection = defineComponent({
this.showMessage({
title,
message: runDataExecutedErrorMessage,
type,
type: 'error',
duration: 0,
dangerouslyUseHTMLString: true,
});
}
}
} else {
// Workflow did execute without a problem
this.titleSet(workflow.name as string, 'IDLE');

View file

@ -4,15 +4,12 @@ import type {
IConnections,
IRunExecutionData,
IExecuteData,
INodeType,
INodeTypeData,
INodeTypes,
IVersionedNodeType,
} from 'n8n-workflow';
import { Workflow, WorkflowDataProxy, NodeHelpers } from 'n8n-workflow';
import { WorkflowDataProxy } from 'n8n-workflow';
import { createTestWorkflowObject } from '@/__tests__/mocks';
class NodeTypesClass implements INodeTypes {
nodeTypes: INodeTypeData = {
const nodeTypes: INodeTypeData = {
'test.set': {
sourcePath: '',
type: {
@ -45,16 +42,7 @@ class NodeTypesClass implements INodeTypes {
},
},
},
};
getByName(nodeType: string): INodeType | IVersionedNodeType {
return this.nodeTypes[nodeType].type;
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
}
}
};
const nodes: INode[] = [
{
@ -273,13 +261,13 @@ const runExecutionData: IRunExecutionData = {
},
};
const workflow = new Workflow({
const workflow = createTestWorkflowObject({
id: '123',
name: 'test workflow',
nodes,
connections,
active: false,
nodeTypes: new NodeTypesClass(),
nodeTypes,
});
const lastNodeName = 'End';

View file

@ -791,6 +791,7 @@
"ndv.output.pageSize": "Page Size",
"ndv.output.run": "Run",
"ndv.output.runNodeHint": "Execute this node to output data",
"ndv.output.runNodeHintSubNode": "Output will appear here once the parent node is run",
"ndv.output.insertTestData": "insert test data",
"ndv.output.staleDataWarning.regular": "Node parameters have changed.<br>Execute node again to refresh output.",
"ndv.output.staleDataWarning.pinData": "Node parameter changes will not affect pinned output data.",
@ -948,6 +949,7 @@
"nodeErrorView.dataBelowMayContain": "Data below may contain sensitive information. Proceed with caution when sharing.",
"nodeErrorView.details": "Details",
"nodeErrorView.error": "ERROR",
"nodeErrorView.errorSubNode": "Error in sub-node {node}",
"nodeErrorView.httpCode": "HTTP Code",
"nodeErrorView.inParameter": "In or underneath Parameter",
"nodeErrorView.itemIndex": "Item Index",
@ -1240,6 +1242,7 @@
"pushConnection.executionFailed": "Execution failed",
"pushConnection.executionFailed.message": "There might not be enough memory to finish the execution. Tips for avoiding this <a target=\"_blank\" href=\"https://docs.n8n.io/flow-logic/error-handling/memory-errors/\">here</a>",
"pushConnection.executionError": "There was a problem executing the workflow{error}",
"pushConnection.executionError.openNode": " <a data-action='openNodeDetail' data-action-parameter-node='{node}'>Open node</a>",
"pushConnection.executionError.details": "<br /><strong>{details}</strong>",
"resourceLocator.id.placeholder": "Enter ID...",
"resourceLocator.mode.id": "By ID",
@ -1762,6 +1765,7 @@
"nodeIssues.credentials.notIdentified": "Credentials with name {name} exist for {type}.",
"nodeIssues.credentials.notIdentified.hint": "Credentials are not clearly identified. Please select the correct credentials.",
"nodeIssues.input.missing": "No node connected to required input \"{inputName}\"",
"nodeIssues.input.missingSubNode": "On the canvas, <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='{inputType}' data-action-parameter-node='{node}'>add a {inputName}</a> connected to the {node} node ",
"ndv.trigger.moreInfo": "More info",
"ndv.trigger.copiedTestUrl": "Test URL copied to clipboard",
"ndv.trigger.webhookBasedNode.executionsHelp.inactive": "<b>While building your workflow</b>, click the 'listen' button, then go to {service} and make an event happen. This will trigger an execution, which will show up in this editor.<br /> <br /> <b>Once you're happy with your workflow</b>, <a data-key=\"activate\">activate</a> it. Then every time there's a matching event in {service}, the workflow will execute. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",

View file

@ -16,7 +16,7 @@ export const register = () => {
const sizeDifference = (unconnectedPlusSize - unconnectedDiamondWidth) / 2;
const container = svg.node('g', {
style: `--svg-color: var(${endpointInstance.params.color})`,
style: `--svg-color: var(--endpoint-svg-color, var(${endpointInstance.params.color}))`,
width,
height,
});

View file

@ -46,6 +46,14 @@ export class N8nAddInputEndpoint extends EndpointRepresentation<ComputedN8nAddIn
this.instance.unbind(EVENT_ENDPOINT_CLICK, this.fireClickEvent);
}
setError() {
this.endpoint.addClass('add-input-endpoint-error');
}
resetError() {
this.endpoint.removeClass('add-input-endpoint-error');
}
fireClickEvent = (endpoint: Endpoint) => {
if (endpoint === this.endpoint) {
this.instance.fire(EVENT_ADD_INPUT_ENDPOINT_CLICK, this.endpoint);

View file

@ -1,7 +1,11 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { v4 as uuid } from 'uuid';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store';
import { useHistoryStore } from '@/stores/history.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { INodeUi, XYPosition } from '@/Interface';
import {
applyScale,
@ -37,12 +41,6 @@ import {
} from '@/utils/nodeViewUtils';
import type { PointXY } from '@jsplumb/util';
import { useWorkflowsStore } from './workflows.store';
import { useNodeTypesStore } from './nodeTypes.store';
import { useUIStore } from './ui.store';
import { useHistoryStore } from './history.store';
import { useSourceControlStore } from './sourceControl.store';
export const useCanvasStore = defineStore('canvas', () => {
const workflowStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();

View file

@ -157,6 +157,26 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
}
acc[outputType].push(node.name);
});
} else {
// If outputs is not an array, it must be a string expression
// in which case we'll try to match all possible non-main output types that are supported
const connectorTypes: ConnectionTypes[] = [
NodeConnectionType.AiVectorStore,
NodeConnectionType.AiChain,
NodeConnectionType.AiDocument,
NodeConnectionType.AiEmbedding,
NodeConnectionType.AiLanguageModel,
NodeConnectionType.AiMemory,
NodeConnectionType.AiOutputParser,
NodeConnectionType.AiTextSplitter,
NodeConnectionType.AiTool,
];
connectorTypes.forEach((outputType: ConnectionTypes) => {
if (outputTypes.includes(outputType)) {
acc[outputType] = acc[outputType] || [];
acc[outputType].push(node.name);
}
});
}
return acc;

View file

@ -663,7 +663,7 @@ export const getOutputSummary = (
} = {};
data.forEach((run: ITaskData) => {
if (!run.data?.[connectionType]) {
if (!run?.data?.[connectionType]) {
return;
}

View file

@ -87,6 +87,7 @@
:renaming="renamingActive"
:isProductionExecutionPreview="isProductionExecutionPreview"
@redrawNode="redrawNode"
@switchSelectedNode="onSwitchSelectedNode"
@valueChanged="valueChanged"
@stopExecution="stopExecution"
@saveKeyboardShortcut="onSaveKeyboardShortcut"
@ -356,7 +357,11 @@ import {
N8nPlusEndpointType,
EVENT_PLUS_ENDPOINT_CLICK,
} from '@/plugins/jsplumb/N8nPlusEndpointType';
import { EVENT_ADD_INPUT_ENDPOINT_CLICK } from '@/plugins/jsplumb/N8nAddInputEndpointType';
import type { N8nAddInputEndpoint } from '@/plugins/jsplumb/N8nAddInputEndpointType';
import {
EVENT_ADD_INPUT_ENDPOINT_CLICK,
N8nAddInputEndpointType,
} from '@/plugins/jsplumb/N8nAddInputEndpointType';
import { sourceControlEventBus } from '@/event-bus/source-control';
import { getConnectorPaintStyleData, OVERLAY_ENDPOINT_ARROW_ID } from '@/utils/nodeViewUtils';
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
@ -788,6 +793,42 @@ export default defineComponent({
});
await this.runWorkflow({});
this.refreshEndpointsErrorsState();
},
resetEndpointsErrors() {
const allEndpoints = Object.values(this.instance.getManagedElements()).flatMap(
(el) => el.endpoints,
);
allEndpoints
.filter((endpoint) => endpoint?.endpoint.type === N8nAddInputEndpointType)
.forEach((endpoint) => {
const n8nAddInputEndpoint = endpoint?.endpoint as N8nAddInputEndpoint;
if (n8nAddInputEndpoint && (endpoint?.connections ?? []).length > 0) {
n8nAddInputEndpoint.resetError();
}
});
},
refreshEndpointsErrorsState() {
const nodeIssues = this.workflowsStore.allNodes.filter((n) => n.issues);
// Set input color to red if there are issues
this.resetEndpointsErrors();
nodeIssues.forEach((node) => {
const managedNode = this.instance.getManagedElement(node.id);
const endpoints = this.instance.getEndpoints(managedNode);
Object.keys(node?.issues?.input ?? {}).forEach((connectionType) => {
const inputEndpointsWithIssues = endpoints.filter(
(e) => e._defaultType.scope === connectionType,
);
inputEndpointsWithIssues.forEach((endpoint) => {
const n8nAddInputEndpoint = endpoint?.endpoint as N8nAddInputEndpoint;
if (n8nAddInputEndpoint) {
n8nAddInputEndpoint.setError();
}
});
});
});
},
onRunContainerClick() {
if (this.containsTrigger && !this.allTriggersDisabled) return;
@ -2398,36 +2439,21 @@ export default defineComponent({
}
this.historyStore.stopRecordingUndo();
},
insertNodeAfterSelected(info: {
sourceId: string;
index: number;
eventSource: NodeCreatorOpenSource;
connection?: Connection;
nodeCreatorView?: string;
outputType?: NodeConnectionType;
endpointUuid?: string;
}) {
const type = info.outputType || NodeConnectionType.Main;
getNodeCreatorFilter(nodeName: string, outputType?: NodeConnectionType) {
let filter;
// Get the node and set it as active that new nodes
// which get created get automatically connected
// to it.
const sourceNode = this.workflowsStore.getNodeById(info.sourceId);
if (!sourceNode) {
return;
}
const workflow = this.getCurrentWorkflow();
const workflowNode = workflow.getNode(nodeName);
if (!workflowNode) return { nodes: [] };
const nodeType = this.nodeTypesStore.getNodeType(sourceNode.type, sourceNode.typeVersion);
const nodeType = this.nodeTypesStore.getNodeType(
workflowNode?.type,
workflowNode.typeVersion,
);
if (nodeType) {
const workflowNode = workflow.getNode(sourceNode.name);
const inputs = NodeHelpers.getNodeInputs(workflow, workflowNode!, nodeType);
const inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, nodeType);
const filterFound = inputs.filter((input) => {
if (typeof input === 'string' || input.type !== info.outputType || !input.filter) {
if (typeof input === 'string' || input.type !== outputType || !input.filter) {
// No filters defined or wrong connection type
return false;
}
@ -2440,6 +2466,26 @@ export default defineComponent({
}
}
return filter;
},
insertNodeAfterSelected(info: {
sourceId: string;
index: number;
eventSource: NodeCreatorOpenSource;
connection?: Connection;
nodeCreatorView?: string;
outputType?: NodeConnectionType;
endpointUuid?: string;
}) {
const type = info.outputType ?? NodeConnectionType.Main;
// Get the node and set it as active that new nodes
// which get created get automatically connected
// to it.
const sourceNode = this.workflowsStore.getNodeById(info.sourceId);
if (!sourceNode) {
return;
}
this.uiStore.lastSelectedNode = sourceNode.name;
this.uiStore.lastSelectedNodeEndpointUuid =
info.endpointUuid ?? info.connection?.target.jtk?.endpoint.uuid;
@ -2464,7 +2510,11 @@ export default defineComponent({
if (isScopedConnection) {
useViewStacks()
.gotoCompatibleConnectionView(type, isOutput, filter)
.gotoCompatibleConnectionView(
type,
isOutput,
this.getNodeCreatorFilter(sourceNode.name, type),
)
.catch((e) => {});
}
},
@ -2688,7 +2738,8 @@ export default defineComponent({
}
}
this.dropPrevented = false;
void this.updateNodesInputIssues();
this.updateNodesInputIssues();
this.resetEndpointsErrors();
} catch (e) {
console.error(e);
}
@ -3616,6 +3667,9 @@ export default defineComponent({
}, recordingTimeout);
}
},
async onSwitchSelectedNode(nodeName: string) {
this.nodeSelectedByName(nodeName, true, true);
},
async redrawNode(nodeName: string) {
// TODO: Improve later
// For now we redraw the node by simply renaming it. Can for sure be
@ -4617,6 +4671,37 @@ export default defineComponent({
sourceControlEventBus.on('pull', this.onSourceControlPull);
this.registerCustomAction({
key: 'openNodeDetail',
action: ({ node }: { node: string }) => {
this.nodeSelectedByName(node, true);
},
});
this.registerCustomAction({
key: 'openSelectiveNodeCreator',
action: ({ connectiontype, node }: { connectiontype: NodeConnectionType; node: string }) => {
this.onToggleNodeCreator({
source: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
createNodeActive: true,
nodeCreatorView: AI_NODE_CREATOR_VIEW,
});
this.ndvStore.activeNodeName = null;
// Select the node so that the node creator knows which node to connect to
const nodeData = this.workflowsStore.getNodeByName(node);
if (connectiontype && nodeData) {
this.insertNodeAfterSelected({
index: 0,
endpointUuid: `${nodeData.id}-input${connectiontype}0`,
eventSource: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
outputType: connectiontype,
sourceId: nodeData.id,
});
}
},
});
this.readOnlyEnvRouteCheck();
},
activated() {
@ -4684,6 +4769,8 @@ export default defineComponent({
document.removeEventListener('keydown', this.keyDown);
document.removeEventListener('keyup', this.keyUp);
this.unregisterCustomAction('showNodeCreator');
this.unregisterCustomAction('openNodeDetail');
this.unregisterCustomAction('openSelectiveNodeCreator');
if (!this.isDemo) {
this.pushStore.pushDisconnect();

View file

@ -22,10 +22,11 @@ export class OpenAiApi implements ICredentialType {
default: '',
},
{
displayName: 'Organization ID',
displayName: 'Organization ID (optional)',
name: 'organizationId',
type: 'string',
default: '',
hint: 'Only required if you belong to multiple organisations',
description:
"For users who belong to multiple organizations, you can set which organization is used for an API request. Usage from these API requests will count against the specified organization's subscription quota.",
},

View file

@ -25,6 +25,9 @@ export class SupabaseApi implements ICredentialType {
name: 'serviceRole',
type: 'string',
default: '',
typeOptions: {
password: true,
},
},
];

View file

@ -1098,6 +1098,7 @@ export interface ILoadOptions {
export interface INodePropertyTypeOptions {
action?: string; // Supported by: button
containerClass?: string; // Supported by: notice
alwaysOpenEditWindow?: boolean; // Supported by: json
codeAutocomplete?: CodeAutocompleteTypes; // Supported by: string
editor?: EditorType; // Supported by: string
@ -1526,6 +1527,7 @@ export interface IPostReceiveSort extends IPostReceiveBase {
}
export type ConnectionTypes =
| 'ai_agent'
| 'ai_chain'
| 'ai_document'
| 'ai_embedding'
@ -1540,6 +1542,8 @@ export type ConnectionTypes =
| 'main';
export const enum NodeConnectionType {
// eslint-disable-next-line @typescript-eslint/naming-convention
AiAgent = 'ai_agent',
// eslint-disable-next-line @typescript-eslint/naming-convention
AiChain = 'ai_chain',
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -2376,3 +2380,4 @@ export type BannerName =
| 'EMAIL_CONFIRMATION';
export type Severity = 'warning' | 'error';
export type Functionality = 'regular' | 'configuration-node';

View file

@ -1,4 +1,4 @@
import type { IDataObject, JsonObject, Severity } from '../../Interfaces';
import type { Functionality, IDataObject, JsonObject, Severity } from '../../Interfaces';
import { ApplicationError } from '../application.error';
interface ExecutionBaseErrorOptions {
@ -21,6 +21,8 @@ export abstract class ExecutionBaseError extends ApplicationError {
severity: Severity = 'error';
functionality: Functionality = 'regular';
constructor(message: string, { cause }: ExecutionBaseErrorOptions) {
const options = cause instanceof Error ? { cause } : {};
super(message, options);

View file

@ -4,7 +4,14 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { parseString } from 'xml2js';
import type { INode, JsonObject, IDataObject, IStatusCodeMessages, Severity } from '..';
import type {
INode,
JsonObject,
IDataObject,
IStatusCodeMessages,
Severity,
Functionality,
} from '../Interfaces';
import { NodeError } from './abstract/node.error';
import { removeCircularRefs } from '../utils';
@ -15,6 +22,7 @@ export interface NodeOperationErrorOptions {
itemIndex?: number;
severity?: Severity;
messageMapping?: { [key: string]: string }; // allows to pass custom mapping for error messages scoped to a node
functionality?: Functionality;
}
interface NodeApiErrorOptions extends NodeOperationErrorOptions {
@ -113,6 +121,7 @@ export class NodeApiError extends NodeError {
runIndex,
itemIndex,
severity,
functionality,
messageMapping,
}: NodeApiErrorOptions = {},
) {
@ -206,6 +215,7 @@ export class NodeApiError extends NodeError {
messageMapping,
);
if (functionality !== undefined) this.context.functionality = functionality;
if (runIndex !== undefined) this.context.runIndex = runIndex;
if (itemIndex !== undefined) this.context.itemIndex = itemIndex;
}

View file

@ -16,6 +16,7 @@ export class NodeOperationError extends NodeError {
if (options.message) this.message = options.message;
if (options.severity) this.severity = options.severity;
if (options.functionality) this.functionality = options.functionality;
this.description = options.description;
this.context.runIndex = options.runIndex;
this.context.itemIndex = options.itemIndex;

View file

@ -965,6 +965,9 @@ importers:
'@sentry/vite-plugin':
specifier: ^2.5.0
version: 2.5.0
'@testing-library/vue':
specifier: ^7.0.0
version: 7.0.0(@vue/compiler-sfc@3.3.4)(vue@3.3.4)
'@types/dateformat':
specifier: ^3.0.0
version: 3.0.1
@ -9722,7 +9725,7 @@ packages:
lodash.uniqby: 4.7.0
node-fetch: 2.6.8
parse-github-url: 1.0.2
regenerator-runtime: 0.13.9
regenerator-runtime: 0.13.11
semver: 7.5.4
transitivePeerDependencies:
- encoding
@ -19248,10 +19251,6 @@ packages:
/regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
/regenerator-runtime@0.13.9:
resolution: {integrity: sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==}
dev: false
/regenerator-transform@0.15.1:
resolution: {integrity: sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==}
dependencies: