1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-09-11 13:15:28 +03:00

fix: Remove node AI Transform node (no-changelog) (#10400)

This commit is contained in:
Shireen Missi 2024-08-14 12:01:14 +01:00 committed by GitHub
parent c4fcbe40c5
commit bae0116d78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 38 additions and 831 deletions

View File

@ -50,9 +50,7 @@ export class ManualChatTrigger implements INodeType {
name: 'openChat',
type: 'button',
typeOptions: {
buttonConfig: {
action: 'openChat',
},
action: 'openChat',
},
default: '',
},

View File

@ -1,4 +1,4 @@
import type { INodePropertyOptions, NodeParameterValueType } from 'n8n-workflow';
import type { INodePropertyOptions } from 'n8n-workflow';
import { Post, RestController } from '@/decorators';
import { getBase } from '@/WorkflowExecuteAdditionalData';
@ -92,28 +92,4 @@ export class DynamicNodeParametersController {
credentials,
);
}
@Post('/action-result')
async getActionResult(
req: DynamicNodeParametersRequest.ActionResult,
): Promise<NodeParameterValueType> {
const { currentNodeParameters, nodeTypeAndVersion, path, credentials, handler, payload } =
req.body;
const additionalData = await getBase(req.user.id, currentNodeParameters);
if (handler) {
return await this.service.getActionResult(
handler,
path,
additionalData,
nodeTypeAndVersion,
currentNodeParameters,
payload,
credentials,
);
}
return;
}
}

View File

@ -419,12 +419,6 @@ export declare namespace DynamicNodeParametersRequest {
type ResourceMapperFields = BaseRequest<{
methodName: string;
}>;
/** POST /dynamic-node-parameters/action-result */
type ActionResult = BaseRequest<{
handler: string;
payload: IDataObject | string | undefined;
}>;
}
// ----------------------------------

View File

@ -15,8 +15,6 @@ import type {
INodeCredentials,
INodeParameters,
INodeTypeNameVersion,
NodeParameterValueType,
IDataObject,
} from 'n8n-workflow';
import { Workflow, RoutingNode, ApplicationError } from 'n8n-workflow';
import { NodeExecuteFunctions } from 'n8n-core';
@ -158,24 +156,6 @@ export class DynamicNodeParametersService {
return method.call(thisArgs);
}
/** Returns the result of the action handler */
async getActionResult(
handler: string,
path: string,
additionalData: IWorkflowExecuteAdditionalData,
nodeTypeAndVersion: INodeTypeNameVersion,
currentNodeParameters: INodeParameters,
payload: IDataObject | string | undefined,
credentials?: INodeCredentials,
): Promise<NodeParameterValueType> {
const nodeType = this.getNodeType(nodeTypeAndVersion);
const method = this.getMethod('actionHandler', handler, nodeType);
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
const thisArgs = this.getThisArg(path, additionalData, workflow);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return method.call(thisArgs, payload);
}
private getMethod(
type: 'resourceMapping',
methodName: string,
@ -195,14 +175,9 @@ export class DynamicNodeParametersService {
methodName: string,
nodeType: INodeType,
): (this: ILoadOptionsFunctions) => Promise<INodePropertyOptions[]>;
private getMethod(
type: 'actionHandler',
methodName: string,
nodeType: INodeType,
): (this: ILoadOptionsFunctions, payload?: string) => Promise<NodeParameterValueType>;
private getMethod(
type: 'resourceMapping' | 'listSearch' | 'loadOptions' | 'actionHandler',
type: 'resourceMapping' | 'listSearch' | 'loadOptions',
methodName: string,
nodeType: INodeType,
) {

View File

@ -1553,11 +1553,6 @@ export declare namespace DynamicNodeParameters {
interface ResourceMapperFieldsRequest extends BaseRequest {
methodName: string;
}
interface ActionResultRequest extends BaseRequest {
handler: string;
payload: IDataObject | string | undefined;
}
}
export interface EnvironmentVariable {

View File

@ -5,7 +5,6 @@ import type {
INodePropertyOptions,
INodeTypeDescription,
INodeTypeNameVersion,
NodeParameterValueType,
ResourceMapperFields,
} from 'n8n-workflow';
import axios from 'axios';
@ -58,15 +57,3 @@ export async function getResourceMapperFields(
sendData,
);
}
export async function getNodeParameterActionResult(
context: IRestApiContext,
sendData: DynamicNodeParameters.ActionResultRequest,
): Promise<NodeParameterValueType> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/action-result',
sendData,
);
}

View File

@ -1,281 +0,0 @@
<script setup lang="ts">
import { ApplicationError, type INodeProperties, type NodePropertyAction } from 'n8n-workflow';
import type { INodeUi, IUpdateInformation } from '@/Interface';
import { ref, computed, onMounted } from 'vue';
import { N8nButton, N8nInput, N8nTooltip } from 'n8n-design-system/components';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useNDVStore } from '@/stores/ndv.store';
import { getSchemas, getParentNodes } from './utils';
import { ASK_AI_EXPERIMENT } from '@/constants';
import { usePostHog } from '@/stores/posthog.store';
import { useRootStore } from '@/stores/root.store';
import { generateCodeForPrompt } from '@/api/ai';
import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
const emit = defineEmits<{
valueChanged: [value: IUpdateInformation];
}>();
const props = defineProps<{
parameter: INodeProperties;
value: string;
path: string;
}>();
const posthog = usePostHog();
const rootStore = useRootStore();
const i18n = useI18n();
const isLoading = ref(false);
const prompt = ref(props.value);
const parentNodes = ref<INodeUi[]>([]);
const hasExecutionData = computed(() => (useNDVStore().ndvInputData || []).length > 0);
const hasInputField = computed(() => props.parameter.typeOptions?.buttonConfig?.hasInputField);
const inputFieldMaxLength = computed(
() => props.parameter.typeOptions?.buttonConfig?.inputFieldMaxLength,
);
const buttonLabel = computed(
() => props.parameter.typeOptions?.buttonConfig?.label ?? props.parameter.displayName,
);
const isSubmitEnabled = computed(() => {
if (!hasExecutionData.value) return false;
if (!prompt.value) return false;
const maxlength = inputFieldMaxLength.value;
if (maxlength && prompt.value.length > maxlength) return false;
return true;
});
function startLoading() {
isLoading.value = true;
}
function stopLoading() {
setTimeout(() => {
isLoading.value = false;
}, 200);
}
function getPath(parameter: string) {
return (props.path ? `${props.path}.` : '') + parameter;
}
function createPrompt(prompt: string) {
return `
Important! The original input must remain unchanged. If there is a risk of modifying the original input, create a copy of it before making any changes. Use appropriate methods to ensure that the properties of objects are not directly altered.
Always return an array
${prompt}
`;
}
async function onSubmit() {
const { activeNode } = useNDVStore();
const { showMessage } = useToast();
const action: string | NodePropertyAction | undefined =
props.parameter.typeOptions?.buttonConfig?.action;
if (!action || !activeNode) return;
if (typeof action === 'string') {
switch (action) {
default:
return;
}
}
emit('valueChanged', {
name: getPath(props.parameter.name),
value: prompt.value,
});
const { type, target } = action;
startLoading();
try {
const schemas = getSchemas();
const version = rootStore.versionCli;
const model =
usePostHog().getVariant(ASK_AI_EXPERIMENT.name) === ASK_AI_EXPERIMENT.gpt4
? 'gpt-4'
: 'gpt-3.5-turbo-16k';
const payload = {
question: createPrompt(prompt.value),
context: {
schema: schemas.parentNodesSchemas,
inputSchema: schemas.inputSchema!,
ndvPushRef: useNDVStore().pushRef,
pushRef: rootStore.pushRef,
},
model,
n8nVersion: version,
};
switch (type) {
case 'askAiCodeGeneration':
let value;
if (posthog.isAiEnabled()) {
const { restApiContext } = useRootStore();
const { code } = await generateCodeForPrompt(restApiContext, payload);
value = code;
} else {
throw new ApplicationError('AI code generation is not enabled');
}
if (value === undefined) return;
const formattedCode = await format(String(value), {
parser: 'babel',
plugins: [jsParser, estree],
});
const updateInformation = {
name: getPath(target as string),
value: formattedCode,
};
emit('valueChanged', updateInformation);
break;
default:
return;
}
showMessage({
type: 'success',
title: i18n.baseText('codeNodeEditor.askAi.generationCompleted'),
});
stopLoading();
} catch (error) {
showMessage({
type: 'error',
title: i18n.baseText('codeNodeEditor.askAi.generationFailed'),
message: error.message,
});
stopLoading();
}
}
function onPromptInput(inputValue: string) {
prompt.value = inputValue;
emit('valueChanged', {
name: getPath(props.parameter.name),
value: inputValue,
});
}
onMounted(() => {
parentNodes.value = getParentNodes();
});
</script>
<template>
<div>
<n8n-input-label
v-if="hasInputField"
:label="i18n.nodeText().inputLabelDisplayName(parameter, path)"
:tooltip-text="i18n.nodeText().inputLabelDescription(parameter, path)"
:bold="false"
size="small"
color="text-dark"
>
</n8n-input-label>
<div :class="$style.inputContainer" :hidden="!hasInputField">
<div :class="$style.meta">
<span
v-if="inputFieldMaxLength"
v-show="prompt.length > 1"
:class="$style.counter"
v-text="`${prompt.length} / ${inputFieldMaxLength}`"
/>
</div>
<N8nInput
v-model="prompt"
:class="$style.input"
style="border: 1px solid var(--color-foreground-base)"
type="textarea"
:rows="6"
:maxlength="inputFieldMaxLength"
:placeholder="parameter.placeholder"
@input="onPromptInput"
/>
</div>
<div :class="$style.controls">
<N8nTooltip :disabled="isSubmitEnabled">
<div>
<N8nButton
:disabled="!isSubmitEnabled"
size="small"
:loading="isLoading"
type="secondary"
@click="onSubmit"
>
{{ buttonLabel }}
</N8nButton>
</div>
<template #content>
<span
v-if="!hasExecutionData"
v-text="i18n.baseText('codeNodeEditor.askAi.noInputData')"
/>
<span
v-else-if="prompt.length === 0"
v-text="i18n.baseText('codeNodeEditor.askAi.noPrompt')"
/>
</template>
</N8nTooltip>
</div>
</div>
</template>
<style module lang="scss">
.input * {
border: 0 !important;
}
.input textarea {
font-size: var(--font-size-2xs);
padding-bottom: var(--spacing-2xl);
font-family: var(--font-family);
resize: none;
}
.intro {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
padding: var(--spacing-2xs) 0 0;
}
.inputContainer {
position: relative;
}
.meta {
display: flex;
justify-content: space-between;
position: absolute;
bottom: var(--spacing-2xs);
left: var(--spacing-xs);
right: var(--spacing-xs);
z-index: 1;
* {
font-size: var(--font-size-2xs);
line-height: 1;
}
}
.counter {
color: var(--color-text-light);
}
.controls {
padding: var(--spacing-2xs) 0;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -1,46 +0,0 @@
import type { Schema } from '@/Interface';
import type { INodeExecutionData } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useDataSchema } from '@/composables/useDataSchema';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
export function getParentNodes() {
const activeNode = useNDVStore().activeNode;
const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore();
const workflow = getCurrentWorkflow();
if (!activeNode || !workflow) return [];
return workflow
.getParentNodesByDepth(activeNode?.name)
.filter(({ name }, i, nodes) => {
return name !== activeNode.name && nodes.findIndex((node) => node.name === name) === i;
})
.map((n) => getNodeByName(n.name))
.filter((n) => n !== null);
}
export function getSchemas() {
const parentNodes = getParentNodes();
const parentNodesNames = parentNodes.map((node) => node?.name);
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
const parentNodesSchemas: Array<{ nodeName: string; schema: Schema }> = parentNodes
.map((node) => {
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
return {
nodeName: node?.name || '',
schema: getSchemaForExecutionData(executionDataToJson(inputData), true),
};
})
.filter((node) => node.schema?.value.length > 0);
const inputSchema = parentNodesSchemas.shift();
return {
parentNodesNames,
inputSchema,
parentNodesSchemas,
};
}

View File

@ -60,12 +60,13 @@ import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { CODE_NODE_TYPE } from '@/constants';
import { ASK_AI_EXPERIMENT, CODE_NODE_TYPE } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
import { useRootStore } from '@/stores/root.store';
import { usePostHog } from '@/stores/posthog.store';
import { useMessage } from '@/composables/useMessage';
import { useSettingsStore } from '@/stores/settings.store';
import AskAI from './AskAI/AskAI.vue';
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
import { useCompleter } from './completer';
@ -113,6 +114,7 @@ const { autocompletionExtension } = useCompleter(() => props.mode, editor);
const { createLinter } = useLinter(() => props.mode, editor);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const posthog = usePostHog();
const i18n = useI18n();
const telemetry = useTelemetry();
@ -189,7 +191,13 @@ onBeforeUnmount(() => {
});
const aiEnabled = computed(() => {
return posthog.isAiEnabled() && props.language === 'javaScript';
const isAiExperimentEnabled = [ASK_AI_EXPERIMENT.gpt3, ASK_AI_EXPERIMENT.gpt4].includes(
(posthog.getVariant(ASK_AI_EXPERIMENT.name) ?? '') as string,
);
return (
isAiExperimentEnabled && settingsStore.settings.ai.enabled && props.language === 'javaScript'
);
});
const placeholder = computed(() => {

View File

@ -1,5 +1,5 @@
<template>
<div :class="$style.editor" :style="isReadOnly ? 'opacity: 0.7' : ''">
<div :class="$style.editor">
<div ref="jsEditorRef" class="ph-no-capture js-editor"></div>
<slot name="suffix" />
</div>
@ -21,7 +21,7 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
autocompleteKeyMap,
@ -45,37 +45,11 @@ const emit = defineEmits<{
}>();
onMounted(() => {
createEditor();
});
watch(
() => props.modelValue,
(newValue: string) => {
const editorValue = editor.value?.state?.doc.toString();
// If model value changes from outside the component
if (
editorValue !== undefined &&
editorValue.length !== newValue.length &&
editorValue !== newValue
) {
destroyEditor();
createEditor();
}
},
);
function createEditor() {
const state = EditorState.create({ doc: props.modelValue, extensions: extensions.value });
const parent = jsEditorRef.value;
editor.value = new EditorView({ parent, state });
editorState.value = editor.value.state;
}
function destroyEditor() {
editor.value?.destroy();
}
});
const jsEditorRef = ref<HTMLDivElement>();
const editor = ref<EditorView | null>(null);

View File

@ -9,7 +9,6 @@ import type {
import {
AI_CATEGORY_AGENTS,
AI_SUBCATEGORY,
AI_TRANSFORM_NODE_TYPE,
CORE_NODES_CATEGORY,
DEFAULT_SUBCATEGORY,
} from '@/constants';
@ -20,8 +19,6 @@ import type { NodeViewItemSection } from './viewsData';
import { i18n } from '@/plugins/i18n';
import { sortBy } from 'lodash-es';
import { usePostHog } from '@/stores/posthog.store';
export function transformNodeType(
node: SimplifiedNodeType,
subcategory?: string,
@ -77,11 +74,6 @@ export function sortNodeCreateElements(nodes: INodeCreateElement[]) {
}
export function searchNodes(searchFilter: string, items: INodeCreateElement[]) {
const aiEnabled = usePostHog().isAiEnabled();
if (!aiEnabled) {
items = items.filter((item) => item.key !== AI_TRANSFORM_NODE_TYPE);
}
// In order to support the old search we need to remove the 'trigger' part
const trimmedFilter = searchFilter.toLowerCase().replace('trigger', '').trimEnd();

View File

@ -1,5 +1,4 @@
import {
AI_TRANSFORM_NODE_TYPE,
CORE_NODES_CATEGORY,
WEBHOOK_NODE_TYPE,
OTHER_TRIGGER_NODES_SUBCATEGORY,
@ -64,7 +63,6 @@ import { NodeConnectionType } from 'n8n-workflow';
import { useTemplatesStore } from '@/stores/templates.store';
import type { BaseTextKey } from '@/plugins/i18n';
import { camelCase } from 'lodash-es';
import { usePostHog } from '@/stores/posthog.store';
export interface NodeViewItemSection {
key: string;
@ -431,13 +429,6 @@ export function TriggerView() {
export function RegularView(nodes: SimplifiedNodeType[]) {
const i18n = useI18n();
const popularItemsSubcategory = [SET_NODE_TYPE, CODE_NODE_TYPE, DATETIME_NODE_TYPE];
const aiEnabled = usePostHog().isAiEnabled();
if (aiEnabled) {
popularItemsSubcategory.push(AI_TRANSFORM_NODE_TYPE);
}
const view: NodeView = {
value: REGULAR_NODE_CREATOR_VIEW,
title: i18n.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
@ -462,7 +453,7 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
{
key: 'popular',
title: i18n.baseText('nodeCreator.sectionNames.popular'),
items: popularItemsSubcategory,
items: [SET_NODE_TYPE, CODE_NODE_TYPE, DATETIME_NODE_TYPE],
},
{
key: 'addOrRemove',

View File

@ -136,14 +136,13 @@
:model-value="modelValueString"
:default-value="parameter.default"
:language="editorLanguage"
:is-read-only="isReadOnly || editorIsReadOnly"
:is-read-only="isReadOnly"
:rows="editorRows"
:ai-button-enabled="settingsStore.isCloudDeployment"
@update:model-value="valueChangedDebounced"
>
<template #suffix>
<n8n-icon
v-if="!editorIsReadOnly"
data-test-id="code-editor-fullscreen-button"
icon="external-link-alt"
size="xsmall"
@ -201,13 +200,12 @@
v-else-if="editorType === 'jsEditor'"
:key="'js-' + codeEditDialogVisible.toString()"
:model-value="modelValueString"
:is-read-only="isReadOnly || editorIsReadOnly"
:is-read-only="isReadOnly"
:rows="editorRows"
@update:model-value="valueChangedDebounced"
>
<template #suffix>
<n8n-icon
v-if="!editorIsReadOnly"
data-test-id="code-editor-fullscreen-button"
icon="external-link-alt"
size="xsmall"
@ -863,9 +861,6 @@ const getIssues = computed<string[]>(() => {
const editorType = computed<EditorType | 'json' | 'code'>(() => {
return getArgument<EditorType>('editor');
});
const editorIsReadOnly = computed<boolean>(() => {
return getArgument<boolean>('editorIsReadOnly') ?? false;
});
const editorLanguage = computed<CodeNodeEditorLanguage>(() => {
if (editorType.value === 'json' || props.parameter.type === 'json')

View File

@ -35,15 +35,14 @@
@action="onNoticeAction"
/>
<div v-else-if="parameter.type === 'button'" class="parameter-item">
<ButtonParameter
:parameter="parameter"
:path="path"
:value="getParameterValue(parameter.name)"
:is-read-only="isReadOnly"
@value-changed="valueChanged"
/>
</div>
<n8n-button
v-else-if="parameter.type === 'button'"
class="parameter-item"
block
@click="onButtonAction(parameter)"
>
{{ $locale.nodeText().inputLabelDisplayName(parameter, path) }}
</n8n-button>
<div
v-else-if="['collection', 'fixedCollection'].includes(parameter.type)"
@ -178,7 +177,6 @@ import AssignmentCollection from '@/components/AssignmentCollection/AssignmentCo
import FilterConditions from '@/components/FilterConditions/FilterConditions.vue';
import ImportCurlParameter from '@/components/ImportCurlParameter.vue';
import MultipleParameter from '@/components/MultipleParameter.vue';
import ButtonParameter from '@/components/ButtonParameter/ButtonParameter.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
@ -485,6 +483,14 @@ function onNoticeAction(action: string) {
* Handles default node button parameter type actions
* @param parameter
*/
function onButtonAction(parameter: INodeProperties) {
const action: string | undefined = parameter.typeOptions?.action;
switch (action) {
default:
return;
}
}
function shouldHideAuthRelatedParameter(parameter: INodeProperties): boolean {
// TODO: For now, hide all fields that are used in authentication fields displayOptions

View File

@ -1,136 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import ButtonParameter from '@/components/ButtonParameter/ButtonParameter.vue';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { usePostHog } from '@/stores/posthog.store';
import { useRootStore } from '@/stores/root.store';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import type { INodeProperties } from 'n8n-workflow';
vi.mock('@/stores/ndv.store');
vi.mock('@/stores/workflows.store');
vi.mock('@/stores/posthog.store');
vi.mock('@/stores/root.store');
vi.mock('@/api/ai');
vi.mock('@/composables/useI18n');
vi.mock('@/composables/useToast');
describe('ButtonParameter', () => {
const defaultProps = {
parameter: {
name: 'testParam',
displayName: 'Test Parameter',
type: 'string',
default: '',
typeOptions: {
buttonConfig: {
label: 'Generate',
action: {
type: 'askAiCodeGeneration',
target: 'targetParam',
},
hasInputField: true,
},
},
} as INodeProperties,
value: '',
path: 'testPath',
};
beforeEach(() => {
vi.mocked(useNDVStore).mockReturnValue({
ndvInputData: [{}],
activeNode: { name: 'TestNode' },
} as any);
vi.mocked(useWorkflowsStore).mockReturnValue({
getCurrentWorkflow: vi.fn().mockReturnValue({
getParentNodesByDepth: vi.fn().mockReturnValue([]),
}),
getNodeByName: vi.fn().mockReturnValue({}),
} as any);
vi.mocked(usePostHog).mockReturnValue({
isAiEnabled: vi.fn().mockReturnValue(true),
getVariant: vi.fn().mockReturnValue('gpt-3.5-turbo-16k'),
} as any);
vi.mocked(useRootStore).mockReturnValue({
versionCli: '1.0.0',
pushRef: 'testPushRef',
} as any);
vi.mocked(useI18n).mockReturnValue({
baseText: vi.fn().mockReturnValue('Mocked Text'),
nodeText: () => ({
inputLabelDisplayName: vi.fn().mockReturnValue('Mocked Display Name'),
inputLabelDescription: vi.fn().mockReturnValue('Mocked Description'),
}),
} as any);
vi.mocked(useToast).mockReturnValue({
showMessage: vi.fn(),
} as any);
});
const mountComponent = (props = defaultProps) => {
return mount(ButtonParameter, {
props,
global: {
plugins: [createTestingPinia()],
},
});
};
it('renders correctly', () => {
const wrapper = mountComponent();
expect(wrapper.find('textarea').exists()).toBe(true);
expect(wrapper.find('button').text()).toBe('Generate');
});
it('emits valueChanged event on input', async () => {
const wrapper = mountComponent();
const input = wrapper.find('textarea');
await input.setValue('Test prompt');
expect(wrapper.emitted('valueChanged')).toBeTruthy();
expect(wrapper.emitted('valueChanged')![0][0]).toEqual({
name: 'testPath.testParam',
value: 'Test prompt',
});
});
it('disables submit button when there is no execution data', async () => {
vi.mocked(useNDVStore).mockReturnValue({
ndvInputData: [],
} as any);
const wrapper = mountComponent();
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
});
it('disables submit button when prompt is empty', async () => {
const wrapper = mountComponent();
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
});
it('enables submit button when there is execution data and prompt', async () => {
const wrapper = mountComponent();
await wrapper.find('textarea').setValue('Test prompt');
expect(wrapper.find('button').attributes('disabled')).toBeUndefined();
});
it('calls onSubmit when button is clicked', async () => {
const wrapper = mountComponent();
await wrapper.find('textarea').setValue('Test prompt');
const submitButton = wrapper.find('button');
expect(submitButton.attributes('disabled')).toBeUndefined();
await submitButton.trigger('click');
expect(useToast().showMessage).toHaveBeenCalled();
});
});

View File

@ -191,7 +191,6 @@ export const CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE =
'@n8n/n8n-nodes-langchain.chainSummarization';
export const SIMULATE_NODE_TYPE = 'n8n-nodes-base.simulate';
export const SIMULATE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.simulateTrigger';
export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform';
export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base';
export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;
@ -211,11 +210,7 @@ export const NON_ACTIVATABLE_TRIGGER_NODE_TYPES = [
MANUAL_CHAT_TRIGGER_NODE_TYPE,
];
export const NODES_USING_CODE_NODE_EDITOR = [
CODE_NODE_TYPE,
AI_CODE_NODE_TYPE,
AI_TRANSFORM_NODE_TYPE,
];
export const NODES_USING_CODE_NODE_EDITOR = [CODE_NODE_TYPE, AI_CODE_NODE_TYPE];
export const PIN_DATA_NODE_TYPES_DENYLIST = [SPLIT_IN_BATCHES_NODE_TYPE, STICKY_NODE_TYPE];

View File

@ -303,12 +303,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
}
};
const getNodeParameterActionResult = async (
sendData: DynamicNodeParameters.ActionResultRequest,
) => {
return await nodeTypesApi.getNodeParameterActionResult(rootStore.restApiContext, sendData);
};
// #endregion
return {
@ -327,7 +321,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
visibleNodeTypesByInputConnectionTypeNames,
isConfigurableNode,
getResourceMapperFields,
getNodeParameterActionResult,
getResourceLocatorResults,
getNodeParameterOptions,
getNodesInformation,

View File

@ -6,11 +6,7 @@ import { useUsersStore } from '@/stores/users.store';
import { useRootStore } from '@/stores/root.store';
import { useSettingsStore } from '@/stores/settings.store';
import type { FeatureFlags, IDataObject } from 'n8n-workflow';
import {
ASK_AI_EXPERIMENT,
EXPERIMENTS_TO_TRACK,
LOCAL_STORAGE_EXPERIMENT_OVERRIDES,
} from '@/constants';
import { EXPERIMENTS_TO_TRACK, LOCAL_STORAGE_EXPERIMENT_OVERRIDES } from '@/constants';
import { useTelemetryStore } from './telemetry.store';
import { useDebounce } from '@/composables/useDebounce';
@ -42,14 +38,6 @@ export const usePostHog = defineStore('posthog', () => {
return overrides.value[experiment] ?? featureFlags.value?.[experiment];
};
const isAiEnabled = () => {
const isAiExperimentEnabled = [ASK_AI_EXPERIMENT.gpt3, ASK_AI_EXPERIMENT.gpt4].includes(
(getVariant(ASK_AI_EXPERIMENT.name) ?? '') as string,
);
return isAiExperimentEnabled && settingsStore.settings.ai.enabled;
};
const isVariantEnabled = (experiment: string, variant: string) => {
return getVariant(experiment) === variant;
};
@ -195,7 +183,6 @@ export const usePostHog = defineStore('posthog', () => {
return {
init,
isAiEnabled,
isFeatureEnabled,
isVariantEnabled,
getVariant,

View File

@ -1,18 +0,0 @@
{
"node": "n8n-nodes-base.aiTransform",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "Modify data by writing a prompt",
"categories": ["Development", "Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.aitransform/"
}
]
},
"alias": ["code", "Javascript", "JS", "Script", "Custom Code", "Function", "AI", "LLM"],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}
}

View File

@ -1,148 +0,0 @@
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import {
NodeOperationError,
type IExecuteFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import set from 'lodash/set';
import { JavaScriptSandbox } from '../Code/JavaScriptSandbox';
import { getSandboxContext } from '../Code/Sandbox';
import { standardizeOutput } from '../Code/utils';
const { CODE_ENABLE_STDOUT } = process.env;
export class AiTransform implements INodeType {
description: INodeTypeDescription = {
displayName: 'AI Transform',
name: 'aiTransform',
icon: 'file:aitransform.svg',
group: ['transform'],
version: 1,
description: 'Modify data based on instructions written in plain english',
defaults: {
name: 'AI Transform',
},
inputs: ['main'],
outputs: ['main'],
parameterPane: 'wide',
properties: [
{
displayName: 'Instructions',
name: 'generate',
type: 'button',
default: '',
description:
"Provide instructions on how you want to transform the data, then click 'Generate code'. Use dot notation to refer to nested fields (e.g. address.street).",
placeholder:
"Example: Merge 'firstname' and 'lastname' into a field 'details.name' and sort by 'email'",
typeOptions: {
buttonConfig: {
label: 'Generate code',
hasInputField: true,
inputFieldMaxLength: 500,
action: {
type: 'askAiCodeGeneration',
target: 'jsCode',
},
},
},
},
{
displayName: 'Transformation Code',
name: 'jsCode',
type: 'string',
typeOptions: {
editor: 'jsEditor',
editorIsReadOnly: true,
},
default: '',
description:
'Read-only. To edit this code, adjust the prompt or copy and paste it into a Code node.',
noDataExpression: true,
},
{
displayName:
"Click on 'Test step' to run the transformation code. Further executions will use the generated code (and not invoke AI again).",
name: 'hint',
type: 'notice',
default: '',
displayOptions: {
show: {
jsCode: [{ _cnd: { exists: true } }],
},
},
},
],
};
async execute(this: IExecuteFunctions) {
const workflowMode = this.getMode();
const node = this.getNode();
const codeParameterName = 'jsCode';
const getSandbox = (index = 0) => {
let code = '';
try {
code = this.getNodeParameter(codeParameterName, index) as string;
if (!code) {
const instructions = this.getNodeParameter('generate', index) as string;
if (!instructions) {
throw new NodeOperationError(node, 'Missing instructions to generate code', {
description:
"Enter your prompt in the 'Instructions' parameter and click 'Generate code'",
});
}
throw new NodeOperationError(node, 'Missing code for data transformation', {
description: "Click the 'Generate code' button to create the code",
});
}
} catch (error) {
if (error instanceof NodeOperationError) throw error;
throw new NodeOperationError(node, error);
}
const context = getSandboxContext.call(this, index);
context.items = context.$input.all();
const Sandbox = JavaScriptSandbox;
const sandbox = new Sandbox(context, code, index, this.helpers);
sandbox.on(
'output',
workflowMode === 'manual'
? this.sendMessageToUI
: CODE_ENABLE_STDOUT === 'true'
? (...args) =>
console.log(`[Workflow "${this.getWorkflow().id}"][Node "${node.name}"]`, ...args)
: () => {},
);
return sandbox;
};
const sandbox = getSandbox();
let items: INodeExecutionData[];
try {
items = (await sandbox.runCodeAllItems()) as INodeExecutionData[];
} catch (error) {
if (!this.continueOnFail(error)) {
set(error, 'node', node);
throw error;
}
items = [{ json: { error: error.message } }];
}
for (const item of items) {
standardizeOutput(item.json);
}
return [items];
}
}

View File

@ -1,10 +0,0 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.4956 19.5195L34.8 18.006C31.7965 17.015 30.0369 14.5846 29.2111 11.5306L27.1447 1.46518C27.0908 1.2663 26.9593 1 26.5548 1C26.2144 1 26.0188 1.2663 25.9649 1.46518L23.8986 11.534C23.0693 14.588 21.3131 17.0184 18.3097 18.0094L13.6141 19.5229C12.95 19.7386 12.9399 20.6757 13.6006 20.9016L18.3299 22.5297C21.3232 23.5241 23.0693 25.9512 23.8986 28.9917L25.9683 38.9458C26.0222 39.1447 26.1335 39.502 26.5582 39.502C26.9977 39.502 27.0906 39.1585 27.1449 38.9577L27.1481 38.9458L29.2178 28.9917C30.047 25.9478 31.7931 23.5208 34.7865 22.5297L39.5158 20.9016C40.1697 20.6724 40.1596 19.7353 39.4956 19.5195ZM12.6028 28.277C9.82036 27.3584 9.54358 26.324 9.07959 24.5899L9.06681 24.5421L7.89038 20.4162C7.81959 20.1499 7.12519 20.1499 7.05103 20.4162L6.25214 24.2286C5.77684 25.9848 4.76558 27.3804 3.0397 27.9501L0.289074 29.1433C-0.0918337 29.2681 -0.0985755 29.8074 0.282332 29.9355L3.05318 30.9164C4.77233 31.4861 5.77684 32.8816 6.25551 34.6311L7.0544 38.2784C7.12856 38.5447 7.81959 38.5447 7.89038 38.2784L8.82748 34.648C9.30277 32.8884 10.0309 31.4895 12.3669 30.9164L14.9692 29.9355C15.3501 29.804 15.3467 29.2647 14.9625 29.14L12.6028 28.277ZM13.3255 2.03231C13.4169 1.65456 13.954 1.64852 14.0473 2.02833L14.0475 2.02889L15.0714 6.25591C15.2111 6.74054 15.5906 7.11618 16.0783 7.2487L18.6822 7.95026C19.0346 8.04918 19.0457 8.54279 18.7009 8.65979L18.7004 8.65996L15.97 9.57684C15.531 9.72415 15.1905 10.0756 15.0538 10.5179L14.0473 14.4327L14.0471 14.4337C13.9524 14.8153 13.4207 14.7996 13.3291 14.4333L12.3559 10.5303C12.2222 10.0878 11.8845 9.73333 11.4489 9.58314L8.72013 8.63869C8.37299 8.51471 8.40082 8.02141 8.75078 7.92932L8.75135 7.92917L11.3368 7.25837C11.8372 7.12876 12.2283 6.74382 12.3616 6.24505L13.3255 2.03231Z" fill="url(#paint0_linear_1373_20357)"/>
<defs>
<linearGradient id="paint0_linear_1373_20357" x1="0" y1="1" x2="47.0035" y2="13.8158" gradientUnits="userSpaceOnUse">
<stop stop-color="#5B60E8"/>
<stop offset="0.5" stop-color="#AA7BEC"/>
<stop offset="1" stop-color="#EC7B8E"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -390,7 +390,6 @@
"dist/nodes/AgileCrm/AgileCrm.node.js",
"dist/nodes/Airtable/Airtable.node.js",
"dist/nodes/Airtable/AirtableTrigger.node.js",
"dist/nodes/AiTransform/AiTransform.node.js",
"dist/nodes/Amqp/Amqp.node.js",
"dist/nodes/Amqp/AmqpTrigger.node.js",
"dist/nodes/ApiTemplateIo/ApiTemplateIo.node.js",

View File

@ -1229,25 +1229,12 @@ export interface ILoadOptions {
};
}
export type NodePropertyAction = {
type: 'askAiCodeGeneration';
handler?: string;
target?: string;
};
export interface INodePropertyTypeOptions {
// Supported by: button
buttonConfig?: {
action?: string | NodePropertyAction;
label?: string; // otherwise "displayName" is used
hasInputField?: boolean;
inputFieldMaxLength?: number; // Supported if hasInputField is true
};
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
editorIsReadOnly?: boolean; // Supported by: string
sqlDialect?: SQLDialect; // Supported by: sqlEditor
loadOptionsDependsOn?: string[]; // Supported by: options
loadOptionsMethod?: string; // Supported by: options
@ -1539,12 +1526,6 @@ export interface INodeType {
resourceMapping?: {
[functionName: string]: (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
};
actionHandler?: {
[functionName: string]: (
this: ILoadOptionsFunctions,
payload: IDataObject | string | undefined,
) => Promise<NodeParameterValueType>;
};
};
webhookMethods?: {
[name in IWebhookDescription['name']]?: {