feat(editor): Deprecate and hide the Ask AI tab in the Code node (#37641)

This commit is contained in:
Bernhard Wittmann 2026-09-03 07:09:43 +00:00 committed by GitHub
parent ffe97a82e7
commit b436bfa584
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 19 additions and 818 deletions

View File

@ -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;
};

View File

@ -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,

View File

@ -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 });

View File

@ -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",

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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>

View File

@ -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;
}

View File

@ -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();

View File

@ -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> {

View File

@ -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();
});
});
});
});
},
);