feat(AI Transform Node): UX improvements (#11280)

Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
Michael Kret 2024-11-05 20:47:52 +02:00 committed by GitHub
parent cb28b5cd60
commit 8a484077af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 237 additions and 72 deletions

View file

@ -32,7 +32,7 @@ const props = withDefaults(defineProps<InputProps>(), {
readonly: false,
clearable: false,
rows: 2,
maxlength: Infinity,
maxlength: undefined,
title: '',
name: () => uid('input'),
autocomplete: 'off',
@ -81,6 +81,7 @@ defineExpose({ focus, blur, select });
:clearable="clearable"
:rows="rows"
:title="title"
:maxlength="maxlength"
v-bind="$attrs"
>
<template v-if="$slots.prepend" #prepend>

View file

@ -44,7 +44,7 @@ describe('ButtonParameter', () => {
beforeEach(() => {
vi.mocked(useNDVStore).mockReturnValue({
ndvInputData: [{}],
activeNode: { name: 'TestNode' },
activeNode: { name: 'TestNode', parameters: {} },
} as any);
vi.mocked(useWorkflowsStore).mockReturnValue({

View file

@ -1,21 +1,16 @@
<script setup lang="ts">
import { ApplicationError, type INodeProperties, type NodePropertyAction } from 'n8n-workflow';
import { 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 { useRootStore } from '@/stores/root.store';
import { getParentNodes, generateCodeForAiTransform } from './utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { generateCodeForPrompt } from '@/api/ai';
import { useUIStore } from '@/stores/ui.store';
import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import { useSettingsStore } from '@/stores/settings.store';
import type { AskAiRequest } from '@/types/assistant.types';
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
const emit = defineEmits<{
valueChanged: [value: IUpdateInformation];
@ -27,8 +22,7 @@ const props = defineProps<{
path: string;
}>();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const { activeNode } = useNDVStore();
const i18n = useI18n();
@ -53,6 +47,11 @@ const isSubmitEnabled = computed(() => {
return true;
});
const promptUpdated = computed(() => {
const lastPrompt = activeNode?.parameters[AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT] as string;
if (!lastPrompt) return false;
return lastPrompt.trim() !== prompt.value.trim();
});
function startLoading() {
isLoading.value = true;
@ -69,7 +68,6 @@ function getPath(parameter: string) {
}
async function onSubmit() {
const { activeNode } = useNDVStore();
const { showMessage } = useToast();
const action: string | NodePropertyAction | undefined =
props.parameter.typeOptions?.buttonConfig?.action;
@ -93,46 +91,26 @@ async function onSubmit() {
startLoading();
try {
const schemas = getSchemas();
const payload: AskAiRequest.RequestPayload = {
question: prompt.value,
context: {
schema: schemas.parentNodesSchemas,
inputSchema: schemas.inputSchema!,
ndvPushRef: useNDVStore().pushRef,
pushRef: rootStore.pushRef,
},
forNode: 'transform',
};
switch (type) {
case 'askAiCodeGeneration':
let value;
if (settingsStore.isAskAiEnabled) {
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,
};
const updateInformation = await generateCodeForAiTransform(
prompt.value,
getPath(target as string),
);
if (!updateInformation) return;
//updade code parameter
emit('valueChanged', updateInformation);
//update code generated for prompt parameter
emit('valueChanged', {
name: getPath(AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT),
value: prompt.value,
});
useTelemetry().trackAiTransform('generationFinished', {
prompt: prompt.value,
code: formattedCode,
code: updateInformation.value,
});
break;
default:
@ -168,6 +146,16 @@ function onPromptInput(inputValue: string) {
});
}
function useDarkBackdrop(): string {
const theme = useUIStore().appliedTheme;
if (theme === 'light') {
return 'background-color: var(--color-background-xlight);';
} else {
return 'background-color: var(--color-background-light);';
}
}
onMounted(() => {
parentNodes.value = getParentNodes();
});
@ -185,13 +173,18 @@ onMounted(() => {
>
</n8n-input-label>
<div :class="$style.inputContainer" :hidden="!hasInputField">
<div :class="$style.meta">
<div :class="$style.meta" :style="useDarkBackdrop()">
<span
v-if="inputFieldMaxLength"
v-show="prompt.length > 1"
:class="$style.counter"
v-text="`${prompt.length} / ${inputFieldMaxLength}`"
/>
<span
v-if="promptUpdated"
:class="$style['warning-text']"
v-text="'Instructions changed'"
/>
</div>
<N8nInput
v-model="prompt"
@ -255,9 +248,15 @@ onMounted(() => {
display: flex;
justify-content: space-between;
position: absolute;
bottom: var(--spacing-2xs);
padding-bottom: var(--spacing-2xs);
padding-top: var(--spacing-2xs);
margin: 1px;
margin-right: var(--spacing-s);
bottom: 0;
left: var(--spacing-xs);
right: var(--spacing-xs);
gap: 10px;
align-items: end;
z-index: 1;
* {
@ -267,10 +266,15 @@ onMounted(() => {
}
.counter {
color: var(--color-text-light);
flex-shrink: 0;
}
.controls {
padding: var(--spacing-2xs) 0;
display: flex;
justify-content: flex-end;
}
.warning-text {
color: var(--color-warning);
line-height: 1.2;
}
</style>

View file

@ -1,9 +1,16 @@
import type { Schema } from '@/Interface';
import type { INodeExecutionData } from 'n8n-workflow';
import { ApplicationError, 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';
import { generateCodeForPrompt } from '../../api/ai';
import { useRootStore } from '../../stores/root.store';
import { type AskAiRequest } from '../../types/assistant.types';
import { useSettingsStore } from '../../stores/settings.store';
import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
export function getParentNodes() {
const activeNode = useNDVStore().activeNode;
@ -44,3 +51,41 @@ export function getSchemas() {
parentNodesSchemas,
};
}
export async function generateCodeForAiTransform(prompt: string, path: string) {
const schemas = getSchemas();
const payload: AskAiRequest.RequestPayload = {
question: prompt,
context: {
schema: schemas.parentNodesSchemas,
inputSchema: schemas.inputSchema!,
ndvPushRef: useNDVStore().pushRef,
pushRef: useRootStore().pushRef,
},
forNode: 'transform',
};
let value;
if (useSettingsStore().isAskAiEnabled) {
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: path,
value: formattedCode,
};
return updateInformation;
}

View file

@ -7,7 +7,12 @@ import {
FORM_TRIGGER_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
} from '@/constants';
import type { INodeTypeDescription } from 'n8n-workflow';
import {
AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT,
AI_TRANSFORM_JS_CODE,
AI_TRANSFORM_NODE_TYPE,
type INodeTypeDescription,
} from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -21,6 +26,8 @@ import { useUIStore } from '@/stores/ui.store';
import { useRouter } from 'vue-router';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { type IUpdateInformation } from '../Interface';
import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils';
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
const MAX_POPUP_COUNT = 10;
@ -47,6 +54,7 @@ const props = withDefaults(
const emit = defineEmits<{
stopExecution: [];
execute: [];
valueChanged: [value: IUpdateInformation];
}>();
defineOptions({
@ -54,6 +62,7 @@ defineOptions({
});
const lastPopupCountUpdate = ref(0);
const codeGenerationInProgress = ref(false);
const router = useRouter();
const { runWorkflow, runWorkflowResolvePending, stopCurrentExecution } = useRunWorkflow({ router });
@ -76,7 +85,7 @@ const nodeType = computed((): INodeTypeDescription | null => {
});
const isNodeRunning = computed(() => {
if (!uiStore.isActionActive['workflowRunning']) return false;
if (!uiStore.isActionActive['workflowRunning'] || codeGenerationInProgress.value) return false;
const triggeredNode = workflowsStore.executedNode;
return (
workflowsStore.isNodeExecuting(node.value?.name ?? '') || triggeredNode === node.value?.name
@ -142,6 +151,10 @@ const disabledHint = computed(() => {
return '';
}
if (codeGenerationInProgress.value) {
return i18n.baseText('ndv.execute.generatingCode');
}
if (isTriggerNode.value && node?.value?.disabled) {
return i18n.baseText('ndv.execute.nodeIsDisabled');
}
@ -163,6 +176,9 @@ const disabledHint = computed(() => {
});
const tooltipText = computed(() => {
if (shouldGenerateCode.value) {
return i18n.baseText('ndv.execute.generateCodeAndTestNode.description');
}
if (disabledHint.value) return disabledHint.value;
if (props.tooltip && !isLoading.value && testStepButtonPopupCount() < MAX_POPUP_COUNT) {
return props.tooltip;
@ -199,9 +215,37 @@ const buttonLabel = computed(() => {
});
const isLoading = computed(
() => isNodeRunning.value && !isListeningForEvents.value && !isListeningForWorkflowEvents.value,
() =>
codeGenerationInProgress.value ||
(isNodeRunning.value && !isListeningForEvents.value && !isListeningForWorkflowEvents.value),
);
const buttonIcon = computed(() => {
if (shouldGenerateCode.value) return 'terminal';
if (!isListeningForEvents.value && !props.hideIcon) return 'flask';
return undefined;
});
const shouldGenerateCode = computed(() => {
if (node.value?.type !== AI_TRANSFORM_NODE_TYPE) {
return false;
}
if (!node.value?.parameters?.instructions) {
return false;
}
if (!node.value?.parameters?.jsCode) {
return true;
}
if (
node.value?.parameters[AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT] &&
(node.value?.parameters?.instructions as string).trim() !==
(node.value?.parameters?.[AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT] as string).trim()
) {
return true;
}
return false;
});
async function stopWaitingForWebhook() {
try {
await workflowsStore.removeTestWebhook(workflowsStore.workflowId);
@ -227,6 +271,51 @@ function onMouseOver() {
}
async function onClick() {
if (shouldGenerateCode.value) {
// Generate code if user hasn't clicked 'Generate Code' button
// and update parameters
codeGenerationInProgress.value = true;
try {
toast.showMessage({
title: i18n.baseText('ndv.execute.generateCode.title'),
message: i18n.baseText('ndv.execute.generateCode.message', {
interpolate: { nodeName: node.value?.name as string },
}),
type: 'success',
});
const prompt = node.value?.parameters?.instructions as string;
const updateInformation = await generateCodeForAiTransform(
prompt,
`parameters.${AI_TRANSFORM_JS_CODE}`,
);
if (!updateInformation) return;
emit('valueChanged', updateInformation);
emit('valueChanged', {
name: `parameters.${AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT}`,
value: prompt,
});
useTelemetry().trackAiTransform('generationFinished', {
prompt,
code: updateInformation.value,
});
} catch (error) {
useTelemetry().trackAiTransform('generationFinished', {
prompt,
code: '',
hasError: true,
});
toast.showMessage({
type: 'error',
title: i18n.baseText('codeNodeEditor.askAi.generationFailed'),
message: error.message,
});
}
codeGenerationInProgress.value = false;
}
if (isChatNode.value || (isChatChild.value && ndvStore.isNDVDataEmpty('input'))) {
ndvStore.setActiveNodeName(null);
nodeViewEventBus.emit('openChat');
@ -296,7 +385,7 @@ async function onClick() {
:label="buttonLabel"
:type="type"
:size="size"
:icon="!isListeningForEvents && !hideIcon ? 'flask' : undefined"
:icon="buttonIcon"
:transparent-background="transparent"
:title="
!isTriggerNode && !tooltipText ? i18n.baseText('ndv.execute.testNode.description') : ''

View file

@ -974,6 +974,7 @@ onBeforeUnmount(() => {
telemetry-source="parameters"
@execute="onNodeExecute"
@stop-execution="onStopExecution"
@value-changed="valueChanged"
/>
</div>
</div>

View file

@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { within } from '@testing-library/vue';
import { waitFor } from '@testing-library/vue';
import ParameterOptions from './ParameterOptions.vue';
import { setActivePinia, createPinia } from 'pinia';
const DEFAULT_PARAMETER = {
displayName: 'Fields to Set',
@ -12,6 +13,17 @@ const DEFAULT_PARAMETER = {
};
describe('ParameterOptions', () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(async () => {
pinia = createPinia();
setActivePinia(pinia);
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders default options properly', () => {
const { getByTestId } = renderComponent(ParameterOptions, {
props: {

View file

@ -4,6 +4,8 @@ import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import { isValueExpression } from '@/utils/nodeTypesUtils';
import { computed } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import { AI_TRANSFORM_NODE_TYPE } from '@/constants';
interface Props {
parameter: INodeProperties;
@ -59,10 +61,19 @@ const shouldShowOptions = computed(() => {
return false;
});
const selectedView = computed(() => (isValueAnExpression.value ? 'expression' : 'fixed'));
const activeNode = computed(() => useNDVStore().activeNode);
const hasRemoteMethod = computed(
() =>
!!props.parameter.typeOptions?.loadOptionsMethod || !!props.parameter.typeOptions?.loadOptions,
);
const resetValueLabel = computed(() => {
if (activeNode.value && [AI_TRANSFORM_NODE_TYPE].includes(activeNode.value.type)) {
return i18n.baseText('parameterInput.clearContents');
}
return i18n.baseText('parameterInput.resetValue');
});
const actions = computed(() => {
if (Array.isArray(props.customActions) && props.customActions.length > 0) {
return props.customActions;
@ -79,7 +90,7 @@ const actions = computed(() => {
const parameterActions = [
{
label: i18n.baseText('parameterInput.resetValue'),
label: resetValueLabel.value,
value: 'resetValue',
disabled: isDefault.value,
},

View file

@ -913,9 +913,13 @@
"ndv.backToCanvas.waitingForTriggerWarning": "Waiting for a Trigger node to execute. Close this view to see the Workflow Canvas.",
"ndv.execute.testNode": "Test step",
"ndv.execute.testNode.description": "Runs the current node. Will also run previous nodes if they have not been run yet",
"ndv.execute.generateCodeAndTestNode.description": "Generates code and then runs the current node",
"ndv.execute.generateCode.message": "The instructions in '{nodeName}' have changed",
"ndv.execute.generateCode.title": "Generating transformation code",
"ndv.execute.executing": "Executing",
"ndv.execute.fetchEvent": "Fetch Test Event",
"ndv.execute.fixPrevious": "Fix previous node first",
"ndv.execute.generatingCode": "Genereting code from instructions",
"ndv.execute.listenForTestEvent": "Listen for test event",
"ndv.execute.testChat": "Test chat",
"ndv.execute.testStep": "Test step",
@ -1383,6 +1387,7 @@
"parameterInput.parameterHasIssues": "Parameter: \"{shortPath}\" has issues",
"parameterInput.parameterHasIssuesAndExpression": "Parameter: \"{shortPath}\" has issues and an expression",
"parameterInput.refreshList": "Refresh List",
"parameterInput.clearContents": "Clear Contents",
"parameterInput.resetValue": "Reset Value",
"parameterInput.select": "Select",
"parameterInput.selectDateAndTime": "Select date and time",

View file

@ -5,6 +5,8 @@ import {
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT,
AI_TRANSFORM_JS_CODE,
} from 'n8n-workflow';
import set from 'lodash/set';
@ -46,36 +48,29 @@ export class AiTransform implements INodeType {
inputFieldMaxLength: 500,
action: {
type: 'askAiCodeGeneration',
target: 'jsCode',
target: AI_TRANSFORM_JS_CODE,
},
},
},
},
{
displayName: 'Transformation Code',
name: 'jsCode',
displayName: 'Code Generated For Prompt',
name: AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT,
type: 'hidden',
default: '',
},
{
displayName: 'Generated JavaScript',
name: AI_TRANSFORM_JS_CODE,
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.',
hint: 'Read-only. To edit this code, adjust the instructions 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 } }],
},
},
},
],
};

View file

@ -117,3 +117,5 @@ export const SINGLE_EXECUTION_NODES: { [key: string]: { [key: string]: NodeParam
};
export const SEND_AND_WAIT_OPERATION = 'sendAndWait';
export const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
export const AI_TRANSFORM_JS_CODE = 'jsCode';