mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
feat(editor): Deprecate and hide the Ask AI tab in the Code node (#37641)
This commit is contained in:
parent
ffe97a82e7
commit
b436bfa584
|
|
@ -197,6 +197,10 @@ export interface FrontendSettings {
|
|||
enabled: boolean;
|
||||
setup: boolean;
|
||||
};
|
||||
/**
|
||||
* @deprecated Gates the AI Transform node's code generation. No longer gates
|
||||
* the Code node's "Ask AI" tab, which is hidden. Removed in v3.
|
||||
*/
|
||||
askAi: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -201,7 +201,13 @@ export class AiController {
|
|||
}
|
||||
}
|
||||
|
||||
@Post('/ask-ai')
|
||||
/**
|
||||
* @deprecated Both callers are deprecated: the Code node's "Ask AI" tab is
|
||||
* hidden, and the AI Transform node is hidden and has an automated migration
|
||||
* to the Code node. Removed in v3.
|
||||
*/
|
||||
@Licensed('feat:askAi')
|
||||
@Post('/ask-ai', { ipRateLimit: { limit: 100 } })
|
||||
async askAi(
|
||||
req: AuthenticatedRequest,
|
||||
_: Response,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export class AiService {
|
|||
return await client.applySuggestion(payload, { id: user.id });
|
||||
}
|
||||
|
||||
/** @deprecated Serves `POST /rest/ai/ask-ai`. Removed in v3. */
|
||||
async askAi(payload: AiAskRequestDto, user: IUser) {
|
||||
const client = await this.getClient();
|
||||
return await client.askAi(payload, { id: user.id });
|
||||
|
|
|
|||
|
|
@ -975,37 +975,10 @@
|
|||
"codeNodeEditor.linter.eachItem.preferFirst": "Prefer `.first()` over `.item` so n8n can optimize execution",
|
||||
"codeNodeEditor.linter.bothModes.syntaxError": "Syntax error",
|
||||
"codeNodeEditor.linter.bothModes.dollarSignVariable": "Use a string literal instead of a variable so n8n can optimize execution.",
|
||||
"codeNodeEditor.askAi.placeholder": "Tell AI what you want the code to achieve. You can reference input data fields using dot notation (e.g. user.email)",
|
||||
"codeNodeEditor.askAi.intro": "Hey AI, generate JavaScript code that...",
|
||||
"codeNodeEditor.askAi.help": "Help",
|
||||
"codeNodeEditor.askAi.generateCode": "Generate Code",
|
||||
"codeNodeEditor.askAi.noInputData": "You can generate code once this node has incoming input data (from a node earlier in your workflow)",
|
||||
"codeNodeEditor.askAi.sureLeaveTab": "Are you sure you want to switch tab? The code generation will stop",
|
||||
"codeNodeEditor.askAi.areYouSure": "Are you sure?",
|
||||
"codeNodeEditor.askAi.switchTab": "Switch Tab",
|
||||
"codeNodeEditor.askAi.noPrompt": "First enter a prompt above before generating code",
|
||||
"codeNodeEditor.askAi.onlyAllItemsMode": "Ask AI generation works only in 'Run Once for All Items' mode",
|
||||
"codeNodeEditor.askAi.promptTooShort": "Enter a minimum of {minLength} characters before attempting to generate code",
|
||||
"codeNodeEditor.askAi.generateCodeAndReplace": "Generate and Replace Code",
|
||||
"codeNodeEditor.askAi.replaceCurrentCode": "Replace current code?",
|
||||
"codeNodeEditor.askAi.areYouSureToReplace": "Are you sure you want to generate new code? Your current code will be replaced.",
|
||||
"codeNodeEditor.askAi.loadingPhrase0": "AI cogs whirring, almost there…",
|
||||
"codeNodeEditor.askAi.loadingPhrase1": "up up down down left right b a start…",
|
||||
"codeNodeEditor.askAi.loadingPhrase2": "Consulting Jan Oberhauser…",
|
||||
"codeNodeEditor.askAi.loadingPhrase3": "Gathering bytes and pieces…",
|
||||
"codeNodeEditor.askAi.loadingPhrase4": "Checking if another AI knows the answer…",
|
||||
"codeNodeEditor.askAi.loadingPhrase5": "Checking on Stack Overflow…",
|
||||
"codeNodeEditor.askAi.loadingPhrase6": "Crunching data, AI-style…",
|
||||
"codeNodeEditor.askAi.loadingPhrase7": "Stand by, AI magic at work…",
|
||||
"codeNodeEditor.askAi.generationCompleted": "✨ Code generation completed",
|
||||
"codeNodeEditor.askAi.generationFailed": "Code generation failed",
|
||||
"codeNodeEditor.askAi.generationFailedUnknown": "Code generation failed due to an unknown reason. Try again in a few minutes",
|
||||
"codeNodeEditor.askAi.generationFailedWithReason": "Code generation failed with error: {error}. Try again in a few minutes",
|
||||
"codeNodeEditor.askAi.generationFailedDown": "We're sorry, our AI service is currently unavailable. Please try again later. If the problem persists, contact support.",
|
||||
"codeNodeEditor.askAi.generationFailedRate": "We've hit our rate limit with our AI partner (too many requests). Please wait a minute before trying again.",
|
||||
"codeNodeEditor.askAi.generationFailedTooLarge": "Your workflow data is too large for AI to process. Simplify the data being sent into the Code node and retry.",
|
||||
"codeNodeEditor.tabs.askAi": "✨ Ask AI",
|
||||
"codeNodeEditor.tabs.code": "Code",
|
||||
"codeNodeEditor.examples": "Examples",
|
||||
"codeNodeEditor.parameters": "Parameters",
|
||||
"codeNodeEditor.optional": "optional",
|
||||
|
|
|
|||
|
|
@ -496,7 +496,6 @@ function onRenameNode(value: string) {
|
|||
:is-read-only="isReadOnly"
|
||||
:target-node-parameter-context="targetNodeParameterContext"
|
||||
fill-parent
|
||||
:disable-ask-ai="true"
|
||||
@update:model-value="onInputChange"
|
||||
/>
|
||||
<HtmlEditor
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export async function replaceCode(
|
|||
);
|
||||
}
|
||||
|
||||
/** @deprecated Calls the deprecated `POST /ai/ask-ai` endpoint. Removed in v3. */
|
||||
export async function generateCodeForPrompt(
|
||||
ctx: IRestApiContext,
|
||||
{ question, context, forNode }: AskAiRequest.RequestPayload,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ import {
|
|||
} from '@/app/constants';
|
||||
|
||||
import { getDebounceTime, useDebounce } from '@n8n/composables/useDebounce';
|
||||
import { useEditorContext } from '@/app/composables/useEditorContext';
|
||||
import { useAiGateway } from '@/app/composables/useAiGateway';
|
||||
import { useExternalHooks } from '@/app/composables/useExternalHooks';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -186,7 +185,6 @@ const ndvStore = injectNDVStoreIfProvided();
|
|||
const workflowsListStore = useWorkflowsListStore();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { askAi } = useEditorContext();
|
||||
const aiGateway = useAiGateway();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
|
|
@ -1637,7 +1635,6 @@ onUpdated(async () => {
|
|||
:default-value="parameter.default"
|
||||
:language="editorLanguage"
|
||||
:is-read-only="isReadOnly"
|
||||
:disable-ask-ai="!askAi"
|
||||
fill-parent
|
||||
@update:model-value="valueChangedDebounced"
|
||||
/>
|
||||
|
|
@ -1710,7 +1707,6 @@ onUpdated(async () => {
|
|||
:is-read-only="isReadOnly || editorIsReadOnly"
|
||||
:rows="editorRows"
|
||||
:ai-button-enabled="settingsStore.isCloudDeployment"
|
||||
:disable-ask-ai="!askAi"
|
||||
@update:model-value="valueChangedDebounced"
|
||||
>
|
||||
<template #suffix>
|
||||
|
|
@ -1853,7 +1849,6 @@ onUpdated(async () => {
|
|||
:language="editorLanguage"
|
||||
:is-read-only="true"
|
||||
:rows="editorRows"
|
||||
:disable-ask-ai="!askAi"
|
||||
/>
|
||||
</div>
|
||||
<N8nInput
|
||||
|
|
|
|||
|
|
@ -1,428 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import snakeCase from 'lodash/snakeCase';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
|
||||
import { N8nButton, N8nCircleLoader, N8nIcon, N8nInput, N8nTooltip } from '@n8n/design-system';
|
||||
import { randomInt } from 'n8n-workflow';
|
||||
import type { CodeExecutionMode, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { INodeUi, Schema } from '@/Interface';
|
||||
import { generateCodeForPrompt } from '@/features/ai/assistant/assistant.api';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { useDataSchema } from '@/app/composables/useDataSchema';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { executionDataToJson } from '@/app/utils/nodeTypesUtils';
|
||||
import {
|
||||
ASK_AI_MAX_PROMPT_LENGTH,
|
||||
ASK_AI_MIN_PROMPT_LENGTH,
|
||||
ASK_AI_LOADING_DURATION_MS,
|
||||
} from '@/app/constants';
|
||||
import type { AskAiRequest } from '@/features/ai/assistant/assistant.types';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
const emit = defineEmits<{
|
||||
submit: [code: string];
|
||||
replaceCode: [code: string];
|
||||
startedLoading: [];
|
||||
finishedLoading: [];
|
||||
}>();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
hasChanges: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isReadOnly: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
|
||||
const i18n = useI18n();
|
||||
const ndvStore = injectNDVStore();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
|
||||
const loadingPhraseIndex = ref(0);
|
||||
const loaderProgress = ref(0);
|
||||
|
||||
const isLoading = ref(false);
|
||||
const prompt = ref('');
|
||||
const parentNodes = ref<INodeUi[]>([]);
|
||||
|
||||
const isSubmitEnabled = computed(() => {
|
||||
return (
|
||||
!isEachItemMode.value &&
|
||||
prompt.value.length >= ASK_AI_MIN_PROMPT_LENGTH &&
|
||||
hasExecutionData.value
|
||||
);
|
||||
});
|
||||
const hasExecutionData = computed(
|
||||
() => (ndvStore.value.ndvInputDataWithPinnedData || []).length > 0,
|
||||
);
|
||||
const loadingString = computed(() =>
|
||||
i18n.baseText(`codeNodeEditor.askAi.loadingPhrase${loadingPhraseIndex.value}` as BaseTextKey),
|
||||
);
|
||||
const isEachItemMode = computed(() => {
|
||||
const mode = ndvStore.value.activeNode?.parameters.mode as CodeExecutionMode;
|
||||
|
||||
return mode === 'runOnceForEachItem';
|
||||
});
|
||||
|
||||
function getErrorMessageByStatusCode(statusCode: number, message: string | undefined): string {
|
||||
const errorMessages: Record<number, string> = {
|
||||
[413]: i18n.baseText('codeNodeEditor.askAi.generationFailedTooLarge'),
|
||||
[400]: i18n.baseText('codeNodeEditor.askAi.generationFailedUnknown'),
|
||||
[429]: i18n.baseText('codeNodeEditor.askAi.generationFailedRate'),
|
||||
[500]: message
|
||||
? i18n.baseText('codeNodeEditor.askAi.generationFailedWithReason', {
|
||||
interpolate: {
|
||||
error: message,
|
||||
},
|
||||
})
|
||||
: i18n.baseText('codeNodeEditor.askAi.generationFailedUnknown'),
|
||||
};
|
||||
|
||||
return errorMessages[statusCode] || i18n.baseText('codeNodeEditor.askAi.generationFailedUnknown');
|
||||
}
|
||||
|
||||
function getParentNodes() {
|
||||
const activeNode = ndvStore.value.activeNode;
|
||||
|
||||
if (!activeNode) return [];
|
||||
|
||||
return workflowDocumentStore.value
|
||||
.getParentNodesByDepth(activeNode?.name)
|
||||
.filter(({ name }, i, nodes) => {
|
||||
return name !== activeNode.name && nodes.findIndex((node) => node.name === name) === i;
|
||||
})
|
||||
.map((n) => workflowDocumentStore.value.getNodeByName(n.name))
|
||||
.filter((n) => n !== null);
|
||||
}
|
||||
|
||||
function getSchemas() {
|
||||
const parentNodesNames = parentNodes.value.map((node) => node?.name);
|
||||
const parentNodesSchemas: Array<{ nodeName: string; schema: Schema }> = parentNodes.value
|
||||
.map((node) => {
|
||||
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
|
||||
|
||||
return {
|
||||
nodeName: node?.name || '',
|
||||
schema: getSchemaForExecutionData(executionDataToJson(inputData), true),
|
||||
};
|
||||
})
|
||||
.filter((node) => node.schema?.value.length > 0);
|
||||
|
||||
// Account for empty objects
|
||||
const inputSchema = parentNodesSchemas.shift() ?? {
|
||||
nodeName: parentNodesNames[0] ?? '',
|
||||
schema: { path: '', type: 'undefined', value: '' },
|
||||
};
|
||||
|
||||
return {
|
||||
parentNodesNames,
|
||||
inputSchema,
|
||||
parentNodesSchemas,
|
||||
};
|
||||
}
|
||||
|
||||
function startLoading() {
|
||||
emit('startedLoading');
|
||||
loaderProgress.value = 0;
|
||||
isLoading.value = true;
|
||||
|
||||
triggerLoadingChange();
|
||||
}
|
||||
|
||||
function stopLoading() {
|
||||
loaderProgress.value = 100;
|
||||
emit('finishedLoading');
|
||||
|
||||
setTimeout(() => {
|
||||
isLoading.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const { restApiContext } = useRootStore();
|
||||
const activeNode = ndvStore.value.activeNode;
|
||||
const { showMessage } = useToast();
|
||||
const { alert } = useMessage();
|
||||
if (!activeNode) return;
|
||||
const schemas = getSchemas();
|
||||
|
||||
if (props.hasChanges) {
|
||||
const confirmModal = await alert(i18n.baseText('codeNodeEditor.askAi.areYouSureToReplace'), {
|
||||
title: i18n.baseText('codeNodeEditor.askAi.replaceCurrentCode'),
|
||||
confirmButtonText: i18n.baseText('codeNodeEditor.askAi.generateCodeAndReplace'),
|
||||
showClose: true,
|
||||
showCancelButton: true,
|
||||
});
|
||||
|
||||
if (confirmModal === 'cancel') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
startLoading();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const payload: AskAiRequest.RequestPayload = {
|
||||
question: prompt.value,
|
||||
context: {
|
||||
schema: schemas.parentNodesSchemas,
|
||||
inputSchema: schemas.inputSchema,
|
||||
ndvPushRef: ndvStore.value.pushRef,
|
||||
pushRef: rootStore.pushRef,
|
||||
},
|
||||
forNode: 'code',
|
||||
};
|
||||
|
||||
try {
|
||||
const { code } = await generateCodeForPrompt(restApiContext, payload);
|
||||
|
||||
stopLoading();
|
||||
emit('replaceCode', code);
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('codeNodeEditor.askAi.generationCompleted'),
|
||||
});
|
||||
useTelemetry().trackAskAI('askAi.generationFinished', ndvStore.value.pushRef, {
|
||||
prompt: prompt.value,
|
||||
code,
|
||||
});
|
||||
} catch (error) {
|
||||
showMessage({
|
||||
type: 'error',
|
||||
title: i18n.baseText('codeNodeEditor.askAi.generationFailed'),
|
||||
message: getErrorMessageByStatusCode(
|
||||
error.httpStatusCode || error?.response.status,
|
||||
error?.message,
|
||||
),
|
||||
});
|
||||
stopLoading();
|
||||
useTelemetry().trackAskAI('askAi.generationFinished', ndvStore.value.pushRef, {
|
||||
prompt: prompt.value,
|
||||
code: '',
|
||||
hasError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
function triggerLoadingChange() {
|
||||
const loadingPhraseUpdateMs = 2000;
|
||||
const loadingPhrasesCount = 8;
|
||||
let start: number | null = null;
|
||||
let lastPhraseChange = 0;
|
||||
const step = (timestamp: number) => {
|
||||
if (!start) start = timestamp;
|
||||
|
||||
// Loading phrase change
|
||||
if (!lastPhraseChange || timestamp - lastPhraseChange >= loadingPhraseUpdateMs) {
|
||||
loadingPhraseIndex.value = randomInt(loadingPhrasesCount);
|
||||
lastPhraseChange = timestamp;
|
||||
}
|
||||
|
||||
// Loader progress change
|
||||
const elapsed = timestamp - start;
|
||||
loaderProgress.value = Math.min((elapsed / ASK_AI_LOADING_DURATION_MS) * 100, 100);
|
||||
|
||||
if (!isLoading.value) return;
|
||||
if (loaderProgress.value < 100 || lastPhraseChange + loadingPhraseUpdateMs > timestamp) {
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function getSessionStoragePrompt() {
|
||||
const codeNodeName = (ndvStore.value.activeNode?.name as string) ?? '';
|
||||
const hashedCode = snakeCase(codeNodeName);
|
||||
|
||||
return useSessionStorage(`ask_ai_prompt__${hashedCode}`, '');
|
||||
}
|
||||
|
||||
function onPromptInput(inputValue: string) {
|
||||
getSessionStoragePrompt().value = inputValue;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Restore prompt from session storage(with empty string fallback)
|
||||
prompt.value = getSessionStoragePrompt().value;
|
||||
parentNodes.value = getParentNodes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p :class="$style.intro" v-text="i18n.baseText('codeNodeEditor.askAi.intro')" />
|
||||
<div :class="$style.inputContainer">
|
||||
<N8nInput
|
||||
v-model="prompt"
|
||||
:class="$style.input"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:maxlength="ASK_AI_MAX_PROMPT_LENGTH"
|
||||
:placeholder="i18n.baseText('codeNodeEditor.askAi.placeholder')"
|
||||
data-test-id="ask-ai-prompt-input"
|
||||
:readonly="props.isReadOnly"
|
||||
@input="onPromptInput"
|
||||
/>
|
||||
<div :class="$style.meta">
|
||||
<span
|
||||
v-show="prompt.length > 1"
|
||||
:class="$style.counter"
|
||||
data-test-id="ask-ai-prompt-counter"
|
||||
v-text="`${prompt.length} / ${ASK_AI_MAX_PROMPT_LENGTH}`"
|
||||
/>
|
||||
<a href="https://docs.n8n.io/code-examples/ai-code" target="_blank" :class="$style.help">
|
||||
<N8nIcon icon="circle-help" color="text-light" size="large" />{{
|
||||
i18n.baseText('codeNodeEditor.askAi.help')
|
||||
}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div v-if="isLoading" :class="$style.loader">
|
||||
<Transition name="text-fade-in-out" mode="out-in">
|
||||
<div :key="loadingPhraseIndex" v-text="loadingString" />
|
||||
</Transition>
|
||||
<N8nCircleLoader :radius="8" :progress="loaderProgress" :stroke-width="3" />
|
||||
</div>
|
||||
<N8nTooltip v-else :disabled="isSubmitEnabled">
|
||||
<div>
|
||||
<N8nButton
|
||||
:disabled="!isSubmitEnabled"
|
||||
size="small"
|
||||
data-test-id="ask-ai-cta"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ i18n.baseText('codeNodeEditor.askAi.generateCode') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<template #content>
|
||||
<span
|
||||
v-if="!hasExecutionData"
|
||||
data-test-id="ask-ai-cta-tooltip-no-input-data"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.noInputData')"
|
||||
/>
|
||||
<span
|
||||
v-else-if="prompt.length === 0"
|
||||
data-test-id="ask-ai-cta-tooltip-no-prompt"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.noPrompt')"
|
||||
/>
|
||||
<span
|
||||
v-else-if="isEachItemMode"
|
||||
data-test-id="ask-ai-cta-tooltip-only-all-items-mode"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.onlyAllItemsMode')"
|
||||
/>
|
||||
<span
|
||||
v-else-if="prompt.length < ASK_AI_MIN_PROMPT_LENGTH"
|
||||
data-test-id="ask-ai-cta-tooltip-prompt-too-short"
|
||||
v-text="
|
||||
i18n.baseText('codeNodeEditor.askAi.promptTooShort', {
|
||||
interpolate: { minLength: ASK_AI_MIN_PROMPT_LENGTH.toString() },
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-fade-in-out-enter-active,
|
||||
.text-fade-in-out-leave-active {
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
transform 0.5s ease-in-out;
|
||||
}
|
||||
.text-fade-in-out-enter,
|
||||
.text-fade-in-out-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
.text-fade-in-out-enter-to,
|
||||
.text-fade-in-out-leave {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style module lang="scss">
|
||||
.input * {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.input textarea {
|
||||
font-size: var(--font-size--2xs);
|
||||
font-family: var(--font-family);
|
||||
resize: none;
|
||||
|
||||
/* Custom scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color--foreground);
|
||||
border-radius: 2px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color--foreground--shade-1);
|
||||
}
|
||||
}
|
||||
.intro {
|
||||
font-weight: var(--font-weight--bold);
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--shade-1);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs) 0;
|
||||
}
|
||||
.loader {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--shade-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
}
|
||||
.help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
text-decoration: underline;
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
* {
|
||||
font-size: var(--font-size--2xs);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
.counter {
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
.controls {
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { ViewUpdate } from '@codemirror/view';
|
||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||
import { format } from 'prettier';
|
||||
import jsParser from 'prettier/plugins/babel';
|
||||
import * as estree from 'prettier/plugins/estree';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||
|
||||
import { CODE_NODE_TYPE } from '@/app/constants';
|
||||
|
|
@ -11,20 +8,15 @@ import { codeNodeEditorEventBus } from '@/app/event-bus';
|
|||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
import { useCodeEditor } from '../../composables/useCodeEditor';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import AskAI from './AskAI/AskAI.vue';
|
||||
import { CODE_PLACEHOLDERS } from './constants';
|
||||
import { useLinter } from './linter';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { dropInCodeEditor } from '../../plugins/codemirror/dragAndDrop';
|
||||
import type { TargetNodeParameterContext } from '@/Interface';
|
||||
import { valueToInsert } from './utils';
|
||||
import DraggableTarget from '@/app/components/DraggableTarget.vue';
|
||||
|
||||
import { ElTabPane, ElTabs } from 'element-plus';
|
||||
export type CodeNodeLanguageOption = CodeNodeEditorLanguage | 'pythonNative';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -37,7 +29,6 @@ type Props = {
|
|||
rows?: number;
|
||||
id?: string;
|
||||
targetNodeParameterContext?: TargetNodeParameterContext;
|
||||
disableAskAi?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
|
@ -48,24 +39,16 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
rows: 4,
|
||||
id: () => crypto.randomUUID(),
|
||||
targetNodeParameterContext: undefined,
|
||||
disableAskAi: false,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const message = useMessage();
|
||||
const tabs = ref(['code', 'ask-ai']);
|
||||
const activeTab = ref('code');
|
||||
const isLoadingAIResponse = ref(false);
|
||||
const codeNodeEditorRef = ref<HTMLDivElement>();
|
||||
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
|
||||
const hasManualChanges = ref(false);
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const settingsStore = useSettingsStore();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
|
||||
const linter = useLinter(
|
||||
|
|
@ -111,47 +94,14 @@ onBeforeUnmount(() => {
|
|||
if (!props.isReadOnly) codeNodeEditorEventBus.off('highlightLine', highlightLine);
|
||||
});
|
||||
|
||||
const askAiEnabled = computed(() => {
|
||||
return !props.disableAskAi && settingsStore.isAskAiEnabled && props.language === 'javaScript';
|
||||
});
|
||||
|
||||
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
|
||||
if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
|
||||
emit('update:modelValue', placeholder.value);
|
||||
}
|
||||
});
|
||||
|
||||
async function onBeforeTabLeave(_activeName: string | number, oldActiveName: string | number) {
|
||||
// Confirm dialog if leaving ask-ai tab during loading
|
||||
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
|
||||
const confirmModal = await message.alert(i18n.baseText('codeNodeEditor.askAi.sureLeaveTab'), {
|
||||
title: i18n.baseText('codeNodeEditor.askAi.areYouSure'),
|
||||
confirmButtonText: i18n.baseText('codeNodeEditor.askAi.switchTab'),
|
||||
showClose: true,
|
||||
showCancelButton: true,
|
||||
});
|
||||
|
||||
return confirmModal === 'confirm';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onAiReplaceCode(code: string) {
|
||||
const formattedCode = await format(code, {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser, estree],
|
||||
});
|
||||
|
||||
emit('update:modelValue', formattedCode);
|
||||
|
||||
activeTab.value = 'code';
|
||||
hasManualChanges.value = false;
|
||||
}
|
||||
|
||||
function onEditorUpdate(viewUpdate: ViewUpdate) {
|
||||
trackCompletion(viewUpdate);
|
||||
hasManualChanges.value = true;
|
||||
emit('update:modelValue', readEditorValue());
|
||||
}
|
||||
|
||||
|
|
@ -196,14 +146,6 @@ function trackCompletion(viewUpdate: ViewUpdate) {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
function onAiLoadStart() {
|
||||
isLoadingAIResponse.value = true;
|
||||
}
|
||||
|
||||
function onAiLoadEnd() {
|
||||
isLoadingAIResponse.value = false;
|
||||
}
|
||||
|
||||
async function onDrop(value: string, event: MouseEvent) {
|
||||
if (!editor.value) return;
|
||||
|
||||
|
|
@ -229,59 +171,7 @@ defineExpose({
|
|||
ref="codeNodeEditorContainerRef"
|
||||
:class="['code-node-editor', $style['code-node-editor-container']]"
|
||||
>
|
||||
<ElTabs
|
||||
v-if="askAiEnabled"
|
||||
ref="tabs"
|
||||
v-model="activeTab"
|
||||
type="card"
|
||||
:before-leave="onBeforeTabLeave"
|
||||
:class="$style.tabs"
|
||||
>
|
||||
<ElTabPane
|
||||
:label="i18n.baseText('codeNodeEditor.tabs.code')"
|
||||
name="code"
|
||||
data-test-id="code-node-tab-code"
|
||||
:class="$style.fillHeight"
|
||||
>
|
||||
<DraggableTarget
|
||||
type="mapping"
|
||||
:disabled="!dragAndDropEnabled"
|
||||
:class="$style.fillHeight"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<div
|
||||
ref="codeNodeEditorRef"
|
||||
:class="[
|
||||
'ph-no-capture',
|
||||
'code-editor-tabs',
|
||||
$style.editorInput,
|
||||
$style.fillHeight,
|
||||
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</DraggableTarget>
|
||||
<slot name="suffix" />
|
||||
</ElTabPane>
|
||||
<ElTabPane
|
||||
:label="i18n.baseText('codeNodeEditor.tabs.askAi')"
|
||||
name="ask-ai"
|
||||
data-test-id="code-node-tab-ai"
|
||||
>
|
||||
<!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
|
||||
<AskAI
|
||||
:key="activeTab"
|
||||
:has-changes="hasManualChanges"
|
||||
:is-read-only="props.isReadOnly"
|
||||
@replace-code="onAiReplaceCode"
|
||||
@started-loading="onAiLoadStart"
|
||||
@finished-loading="onAiLoadEnd"
|
||||
/>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
||||
<div v-else :class="$style.fillHeight">
|
||||
<div :class="$style.fillHeight">
|
||||
<DraggableTarget
|
||||
type="mapping"
|
||||
:disabled="!dragAndDropEnabled"
|
||||
|
|
@ -306,12 +196,6 @@ defineExpose({
|
|||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-tabs) {
|
||||
.cm-editor {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes backgroundAnimation {
|
||||
0% {
|
||||
background-color: none;
|
||||
|
|
@ -333,12 +217,6 @@ defineExpose({
|
|||
</style>
|
||||
|
||||
<style lang="scss" module>
|
||||
.tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-node-editor-container {
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,46 +298,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
await this.clickByTestId('execute-previous-node');
|
||||
}
|
||||
|
||||
async clickAskAiTab() {
|
||||
await this.codeNodeEditor.clickAskAiTab();
|
||||
}
|
||||
|
||||
getAskAiTabPanel() {
|
||||
return this.codeNodeEditor.getAskAiTabPanel();
|
||||
}
|
||||
|
||||
getAskAiCtaButton() {
|
||||
return this.codeNodeEditor.getAskAiCtaButton();
|
||||
}
|
||||
|
||||
getAskAiPromptInput() {
|
||||
return this.codeNodeEditor.getAskAiPromptInput();
|
||||
}
|
||||
|
||||
getAskAiPromptCounter() {
|
||||
return this.codeNodeEditor.getAskAiPromptCounter();
|
||||
}
|
||||
|
||||
getAskAiCtaTooltipNoInputData() {
|
||||
return this.codeNodeEditor.getAskAiCtaTooltipNoInputData();
|
||||
}
|
||||
|
||||
getAskAiCtaTooltipNoPrompt() {
|
||||
return this.codeNodeEditor.getAskAiCtaTooltipNoPrompt();
|
||||
}
|
||||
|
||||
getAskAiCtaTooltipPromptTooShort() {
|
||||
return this.codeNodeEditor.getAskAiCtaTooltipPromptTooShort();
|
||||
}
|
||||
|
||||
getCodeTabPanel() {
|
||||
return this.codeNodeEditor.getCodeTabPanel();
|
||||
}
|
||||
|
||||
getCodeTab() {
|
||||
return this.codeNodeEditor.getCodeTab();
|
||||
}
|
||||
|
||||
getCodeEditor() {
|
||||
return this.codeNodeEditor.getCodeEditor();
|
||||
}
|
||||
|
|
@ -354,18 +314,6 @@ export class NodeDetailsViewPage extends BasePage {
|
|||
return this.page.getByText(text);
|
||||
}
|
||||
|
||||
getHeyAiText() {
|
||||
return this.codeNodeEditor.getHeyAiText();
|
||||
}
|
||||
|
||||
getCodeGenerationCompletedText() {
|
||||
return this.codeNodeEditor.getCodeGenerationCompletedText();
|
||||
}
|
||||
|
||||
getErrorMessageText(message: string) {
|
||||
return this.codeNodeEditor.getErrorMessageText(message);
|
||||
}
|
||||
|
||||
async setParameterDropdown(parameterName: string, optionText: string): Promise<void> {
|
||||
await this.getParameterInput(parameterName).click();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,10 @@ import type { Locator, Page } from '@playwright/test';
|
|||
* Page object for the Code-node editor surface.
|
||||
*
|
||||
* Cohesive cluster spanning three render targets:
|
||||
* - in-NDV roots: the Code / Ask-AI tabs (`code-node-tab-code`,
|
||||
* `code-node-tab-ai`), the Ask-AI CTA + prompt, the
|
||||
* `code-editor-fullscreen-button` and the in-NDV CodeMirror editor for the
|
||||
* `jsCode` parameter
|
||||
* - teleported overlays resolved through the derived `Page`: Ask-AI CTA
|
||||
* tooltips (`ask-ai-cta-tooltip-*`) and the CodeMirror lint tooltip
|
||||
* (`.cm-tooltip-lint`)
|
||||
* - in-NDV roots: the `code-editor-fullscreen-button` and the in-NDV
|
||||
* CodeMirror editor for the `jsCode` parameter
|
||||
* - teleported overlays resolved through the derived `Page`: the CodeMirror
|
||||
* lint tooltip (`.cm-tooltip-lint`)
|
||||
* - teleported fullscreen code editor (`code-editor-fullscreen`). Its hosting
|
||||
* `.el-dialog` shell is owned by `BaseModal` / `dialogLocators`; this
|
||||
* component scopes only to its own teleport root.
|
||||
|
|
@ -20,8 +17,7 @@ import type { Locator, Page } from '@playwright/test';
|
|||
* readonly codeNodeEditor = new CodeNodeEditor(this.container);
|
||||
*
|
||||
* // Inside a test
|
||||
* await n8n.ndv.clickAskAiTab();
|
||||
* await n8n.ndv.getAskAiPromptInput().fill('Some prompt');
|
||||
* await n8n.ndv.getCodeEditor().fill('return items;');
|
||||
*/
|
||||
export class CodeNodeEditor {
|
||||
private readonly page: Page;
|
||||
|
|
@ -30,16 +26,6 @@ export class CodeNodeEditor {
|
|||
this.page = root.page();
|
||||
}
|
||||
|
||||
// --- Code tab ---
|
||||
|
||||
getCodeTabPanel(): Locator {
|
||||
return this.root.getByTestId('code-node-tab-code');
|
||||
}
|
||||
|
||||
getCodeTab(): Locator {
|
||||
return this.root.locator('#tab-code');
|
||||
}
|
||||
|
||||
/** In-NDV CodeMirror editor for the `jsCode` parameter. */
|
||||
getCodeEditor(): Locator {
|
||||
return this.getJsCodeParameter().locator('.cm-content');
|
||||
|
|
@ -54,58 +40,6 @@ export class CodeNodeEditor {
|
|||
return this.page.locator('.cm-tooltip-lint');
|
||||
}
|
||||
|
||||
// --- Ask-AI tab ---
|
||||
|
||||
async clickAskAiTab(): Promise<void> {
|
||||
await this.root.locator('#tab-ask-ai').click();
|
||||
}
|
||||
|
||||
getAskAiTabPanel(): Locator {
|
||||
return this.root.getByTestId('code-node-tab-ai');
|
||||
}
|
||||
|
||||
getAskAiCtaButton(): Locator {
|
||||
return this.root.getByTestId('ask-ai-cta');
|
||||
}
|
||||
|
||||
getAskAiPromptInput(): Locator {
|
||||
return this.root.getByTestId('ask-ai-prompt-input');
|
||||
}
|
||||
|
||||
getAskAiPromptCounter(): Locator {
|
||||
return this.root.getByTestId('ask-ai-prompt-counter');
|
||||
}
|
||||
|
||||
/** Teleported Ask-AI CTA tooltips. */
|
||||
getAskAiCtaTooltipNoInputData(): Locator {
|
||||
return this.page.getByTestId('ask-ai-cta-tooltip-no-input-data');
|
||||
}
|
||||
|
||||
getAskAiCtaTooltipNoPrompt(): Locator {
|
||||
return this.page.getByTestId('ask-ai-cta-tooltip-no-prompt');
|
||||
}
|
||||
|
||||
getAskAiCtaTooltipPromptTooShort(): Locator {
|
||||
return this.page.getByTestId('ask-ai-cta-tooltip-prompt-too-short');
|
||||
}
|
||||
|
||||
// --- Ask-AI status / error text ---
|
||||
|
||||
/** In-panel placeholder rendered inside the Ask-AI tab panel by `AskAI.vue`. */
|
||||
getHeyAiText(): Locator {
|
||||
return this.getAskAiTabPanel().getByText('Hey AI, generate JavaScript');
|
||||
}
|
||||
|
||||
/** Toast — rendered by the global notification system and teleported to `document.body`. */
|
||||
getCodeGenerationCompletedText(): Locator {
|
||||
return this.page.getByText('Code generation completed');
|
||||
}
|
||||
|
||||
/** Toast — rendered by the global notification system and teleported to `document.body`. */
|
||||
getErrorMessageText(message: string): Locator {
|
||||
return this.page.getByText(message);
|
||||
}
|
||||
|
||||
// --- Fullscreen ---
|
||||
|
||||
async openFullscreen(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
CODE_NODE_DISPLAY_NAME,
|
||||
CODE_NODE_NAME,
|
||||
|
|
@ -116,113 +114,5 @@ return []
|
|||
await expect(n8n.ndv.getLintErrors()).toHaveCount(7);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Ask AI', () => {
|
||||
test.describe('Enabled', () => {
|
||||
test.beforeEach(async ({ api, n8n }) => {
|
||||
await api.enableFeature('askAi');
|
||||
await n8n.start.fromBlankCanvas();
|
||||
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
|
||||
});
|
||||
|
||||
test('tab should exist if experiment selected and be selectable', async ({ n8n }) => {
|
||||
await n8n.ndv.clickAskAiTab();
|
||||
await expect(n8n.ndv.getAskAiTabPanel()).toBeVisible();
|
||||
await expect(n8n.ndv.getHeyAiText()).toBeVisible();
|
||||
});
|
||||
|
||||
test('generate code button should have correct state & tooltips', async ({ n8n }) => {
|
||||
await n8n.ndv.clickAskAiTab();
|
||||
await expect(n8n.ndv.getAskAiTabPanel()).toBeVisible();
|
||||
|
||||
await expect(n8n.ndv.getAskAiCtaButton()).toBeDisabled();
|
||||
await n8n.ndv.getAskAiCtaButton().hover();
|
||||
await expect(n8n.ndv.getAskAiCtaTooltipNoInputData()).toBeVisible();
|
||||
|
||||
await n8n.ndv.executePrevious();
|
||||
await n8n.ndv.getAskAiCtaButton().hover();
|
||||
await expect(n8n.ndv.getAskAiCtaTooltipNoPrompt()).toBeVisible();
|
||||
|
||||
await n8n.ndv.getAskAiPromptInput().fill(nanoid(14));
|
||||
|
||||
await n8n.ndv.getAskAiCtaButton().hover();
|
||||
await expect(n8n.ndv.getAskAiCtaTooltipPromptTooShort()).toBeVisible();
|
||||
|
||||
await n8n.ndv.getAskAiPromptInput().fill(nanoid(15));
|
||||
await expect(n8n.ndv.getAskAiCtaButton()).toBeEnabled();
|
||||
|
||||
await expect(n8n.ndv.getAskAiPromptCounter()).toContainText('15 / 600');
|
||||
});
|
||||
|
||||
test('should send correct schema and replace code', async ({ n8n }) => {
|
||||
const prompt = nanoid(20);
|
||||
await n8n.ndv.clickAskAiTab();
|
||||
await n8n.ndv.executePrevious();
|
||||
|
||||
await n8n.ndv.getAskAiPromptInput().fill(prompt);
|
||||
|
||||
await n8n.page.route('**/rest/ai/ask-ai', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
code: 'console.log("Hello World")',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const [request] = await Promise.all([
|
||||
n8n.page.waitForRequest('**/rest/ai/ask-ai'),
|
||||
n8n.ndv.getAskAiCtaButton().click(),
|
||||
]);
|
||||
|
||||
const requestBody = request.postDataJSON();
|
||||
expect(requestBody).toHaveProperty('question');
|
||||
expect(requestBody).toHaveProperty('context');
|
||||
expect(requestBody).toHaveProperty('forNode');
|
||||
expect(requestBody.context).toHaveProperty('schema');
|
||||
expect(requestBody.context).toHaveProperty('ndvPushRef');
|
||||
expect(requestBody.context).toHaveProperty('pushRef');
|
||||
expect(requestBody.context).toHaveProperty('inputSchema');
|
||||
|
||||
await expect(n8n.ndv.getCodeGenerationCompletedText()).toBeVisible();
|
||||
await expect(n8n.ndv.getCodeTabPanel()).toContainText('console.log("Hello World")');
|
||||
await expect(n8n.ndv.getCodeTab()).toHaveClass(/is-active/);
|
||||
});
|
||||
|
||||
const handledCodes = [
|
||||
{ code: 400, message: 'Code generation failed due to an unknown reason' },
|
||||
{ code: 413, message: 'Your workflow data is too large for AI to process' },
|
||||
{ code: 429, message: "We've hit our rate limit with our AI partner" },
|
||||
{
|
||||
code: 500,
|
||||
message:
|
||||
'Code generation failed with error: Request failed with status code 500. Try again in a few minutes',
|
||||
},
|
||||
];
|
||||
|
||||
handledCodes.forEach(({ code, message }) => {
|
||||
test(`should show error based on status code ${code}`, async ({ n8n }) => {
|
||||
const prompt = nanoid(20);
|
||||
await n8n.ndv.clickAskAiTab();
|
||||
await n8n.ndv.executePrevious();
|
||||
|
||||
await n8n.ndv.getAskAiPromptInput().fill(prompt);
|
||||
|
||||
await n8n.page.route('**/rest/ai/ask-ai', async (route) => {
|
||||
await route.fulfill({
|
||||
status: code,
|
||||
});
|
||||
});
|
||||
|
||||
await n8n.ndv.getAskAiCtaButton().click();
|
||||
await expect(n8n.ndv.getErrorMessageText(message)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user