feat(editor): Easy $fromAI Button for AI Tools (#12587)

This commit is contained in:
Charlie Kolb 2025-02-05 08:42:50 +01:00 committed by GitHub
parent 182fc150be
commit 21773764d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1711 additions and 328 deletions

View file

@ -225,7 +225,7 @@ export function populateMapperFields(fields: ReadonlyArray<[string, string]>) {
getParameterInputByName(name).type(value);
// Click on a parent to dismiss the pop up which hides the field below.
getParameterInputByName(name).parent().parent().parent().click('topLeft');
getParameterInputByName(name).parent().parent().parent().parent().click('topLeft');
}
}

View file

@ -62,12 +62,14 @@ describe('n8n Form Trigger', () => {
getVisibleSelect().contains('Dropdown').click();
cy.contains('button', 'Add Field Option').click();
cy.contains('label', 'Field Options')
.parent()
.parent()
.nextAll()
.find('[data-test-id="parameter-input-field"]')
.eq(0)
.type('Option 1');
cy.contains('label', 'Field Options')
.parent()
.parent()
.nextAll()
.find('[data-test-id="parameter-input-field"]')

View file

@ -90,8 +90,8 @@ describe('Sub-workflow creation and typed usage', () => {
clickExecuteNode();
const expected = [
['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'],
['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'],
['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected);
@ -110,8 +110,8 @@ describe('Sub-workflow creation and typed usage', () => {
clickExecuteNode();
const expected2 = [
['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'],
['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'],
['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected2);
@ -167,8 +167,8 @@ describe('Sub-workflow creation and typed usage', () => {
);
assertOutputTableContent([
['[null]', '[null]', '[null]', '[null]', '[null]', 'false'],
['[null]', '[null]', '[null]', '[null]', '[null]', 'false'],
['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
]);
clickExecuteNode();

View file

@ -107,7 +107,7 @@ describe('LoadNodesAndCredentials', () => {
};
fullNodeWrapper.description.properties = [existingProp];
const result = instance.convertNodeToAiTool(fullNodeWrapper);
expect(result.description.properties).toHaveLength(3); // Existing prop + toolDescription + notice
expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription
expect(result.description.properties).toContainEqual(existingProp);
});
@ -121,9 +121,9 @@ describe('LoadNodesAndCredentials', () => {
};
fullNodeWrapper.description.properties = [resourceProp];
const result = instance.convertNodeToAiTool(fullNodeWrapper);
expect(result.description.properties[1].name).toBe('descriptionType');
expect(result.description.properties[2].name).toBe('toolDescription');
expect(result.description.properties[3]).toEqual(resourceProp);
expect(result.description.properties[0].name).toBe('descriptionType');
expect(result.description.properties[1].name).toBe('toolDescription');
expect(result.description.properties[2]).toEqual(resourceProp);
});
it('should handle nodes with operation property', () => {
@ -136,9 +136,9 @@ describe('LoadNodesAndCredentials', () => {
};
fullNodeWrapper.description.properties = [operationProp];
const result = instance.convertNodeToAiTool(fullNodeWrapper);
expect(result.description.properties[1].name).toBe('descriptionType');
expect(result.description.properties[2].name).toBe('toolDescription');
expect(result.description.properties[3]).toEqual(operationProp);
expect(result.description.properties[0].name).toBe('descriptionType');
expect(result.description.properties[1].name).toBe('toolDescription');
expect(result.description.properties[2]).toEqual(operationProp);
});
it('should handle nodes with both resource and operation properties', () => {
@ -158,17 +158,17 @@ describe('LoadNodesAndCredentials', () => {
};
fullNodeWrapper.description.properties = [resourceProp, operationProp];
const result = instance.convertNodeToAiTool(fullNodeWrapper);
expect(result.description.properties[1].name).toBe('descriptionType');
expect(result.description.properties[2].name).toBe('toolDescription');
expect(result.description.properties[3]).toEqual(resourceProp);
expect(result.description.properties[4]).toEqual(operationProp);
expect(result.description.properties[0].name).toBe('descriptionType');
expect(result.description.properties[1].name).toBe('toolDescription');
expect(result.description.properties[2]).toEqual(resourceProp);
expect(result.description.properties[3]).toEqual(operationProp);
});
it('should handle nodes with empty properties', () => {
fullNodeWrapper.description.properties = [];
const result = instance.convertNodeToAiTool(fullNodeWrapper);
expect(result.description.properties).toHaveLength(2);
expect(result.description.properties[1].name).toBe('toolDescription');
expect(result.description.properties).toHaveLength(1);
expect(result.description.properties[0].name).toBe('toolDescription');
});
it('should handle nodes with existing codex property', () => {

View file

@ -701,6 +701,7 @@ export class TelemetryEventRelay extends EventRelay {
sharing_role: userRole,
credential_type: null,
is_managed: false,
...TelemetryHelpers.resolveAIMetrics(workflow.nodes, this.nodeTypes),
};
if (!manualExecEventProperties.node_graph_string) {

View file

@ -484,14 +484,6 @@ export class LoadNodesAndCredentials {
placeholder: `e.g. ${item.description.description}`,
};
const noticeProp: INodeProperties = {
displayName:
"Use the expression {{ $fromAI('placeholder_name') }} for any data to be filled by the model",
name: 'notice',
type: 'notice',
default: '',
};
item.description.properties.unshift(descProp);
// If node has resource or operation we can determine pre-populate tool description based on it
@ -505,8 +497,6 @@ export class LoadNodesAndCredentials {
},
};
}
item.description.properties.unshift(noticeProp);
}
}

View file

@ -31,34 +31,39 @@ exports[`components > N8nCheckbox > should render with both child and label 1`]
class="container"
data-test-id="input-label"
>
<label
class="n8n-input-label inputLabel heading medium"
<div
class="labelRow"
>
<div
class="main-content"
<label
class="n8n-input-label inputLabel heading medium"
>
<div
class="title"
class="main-content"
>
<span
class="n8n-text size-medium regular"
<div
class="title"
>
Checkbox
<!--v-if-->
</span>
<span
class="n8n-text size-medium regular"
>
Checkbox
<!--v-if-->
</span>
</div>
<!--v-if-->
</div>
<!--v-if-->
</div>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<!--v-if-->
</div>
</div>
@ -131,34 +136,39 @@ exports[`components > N8nCheckbox > should render with label 1`] = `
class="container"
data-test-id="input-label"
>
<label
class="n8n-input-label inputLabel heading medium"
<div
class="labelRow"
>
<div
class="main-content"
<label
class="n8n-input-label inputLabel heading medium"
>
<div
class="title"
class="main-content"
>
<span
class="n8n-text size-medium regular"
<div
class="title"
>
Checkbox
<!--v-if-->
</span>
<span
class="n8n-text size-medium regular"
>
Checkbox
<!--v-if-->
</span>
</div>
<!--v-if-->
</div>
<!--v-if-->
</div>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<!--v-if-->
</div>
</div>

View file

@ -33,41 +33,46 @@ exports[`FormBox > should render the component 1`] = `
class="container"
data-test-id="name"
>
<label
class="n8n-input-label inputLabel heading small"
for="name"
<div
class="labelRow"
>
<div
class="main-content"
<label
class="n8n-input-label inputLabel heading small"
for="name"
>
<div
class="title"
class="main-content"
>
<span
class="n8n-text size-small bold"
<div
class="title"
>
Name
<span
class="n8n-text primary size-small bold"
class="n8n-text size-small bold"
>
*
Name
<span
class="n8n-text primary size-small bold"
>
*
</span>
</span>
</span>
</div>
<!--v-if-->
</div>
<!--v-if-->
</div>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<!--v-if-->
</div>
<div
class=""
@ -114,41 +119,46 @@ exports[`FormBox > should render the component 1`] = `
class="container"
data-test-id="email"
>
<label
class="n8n-input-label inputLabel heading medium"
for="email"
<div
class="labelRow"
>
<div
class="main-content"
<label
class="n8n-input-label inputLabel heading medium"
for="email"
>
<div
class="title"
class="main-content"
>
<span
class="n8n-text size-medium bold"
<div
class="title"
>
Email
<span
class="n8n-text primary size-medium bold"
class="n8n-text size-medium bold"
>
*
Email
<span
class="n8n-text primary size-medium bold"
>
*
</span>
</span>
</span>
</div>
<!--v-if-->
</div>
<!--v-if-->
</div>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<!--v-if-->
</div>
<div
class=""
@ -195,41 +205,46 @@ exports[`FormBox > should render the component 1`] = `
class="container"
data-test-id="password"
>
<label
class="n8n-input-label inputLabel heading medium"
for="password"
<div
class="labelRow"
>
<div
class="main-content"
<label
class="n8n-input-label inputLabel heading medium"
for="password"
>
<div
class="title"
class="main-content"
>
<span
class="n8n-text size-medium bold"
<div
class="title"
>
Password
<span
class="n8n-text primary size-medium bold"
class="n8n-text size-medium bold"
>
*
Password
<span
class="n8n-text primary size-medium bold"
>
*
</span>
</span>
</span>
</div>
<!--v-if-->
</div>
<!--v-if-->
</div>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<!--v-if-->
</div>
<div
class=""

View file

@ -41,66 +41,75 @@ const addTargetBlank = (html: string) =>
v-bind="$attrs"
data-test-id="input-label"
>
<label
v-if="label || $slots.options"
:for="inputName"
:class="{
'n8n-input-label': true,
[$style.inputLabel]: true,
[$style.heading]: !!label,
[$style.underline]: underline,
[$style[size]]: true,
[$style.overflow]: !!$slots.options,
}"
>
<div :class="$style['main-content']">
<div v-if="label" :class="$style.title">
<N8nText
:bold="bold"
:size="size"
:compact="compact"
:color="color"
:class="{
[$style.textEllipses]: showOptions,
}"
<div :class="$style.labelRow">
<label
v-if="label || $slots.options"
:for="inputName"
:class="{
'n8n-input-label': true,
[$style.inputLabel]: true,
[$style.heading]: !!label,
[$style.underline]: underline,
[$style[size]]: true,
[$style.overflow]: !!$slots.options,
}"
>
<div :class="$style['main-content']">
<div v-if="label" :class="$style.title">
<N8nText
:bold="bold"
:size="size"
:compact="compact"
:color="color"
:class="{
[$style.textEllipses]: showOptions,
}"
>
{{ label }}
<N8nText v-if="required" color="primary" :bold="bold" :size="size">*</N8nText>
</N8nText>
</div>
<span
v-if="tooltipText && label"
:class="[$style.infoIcon, showTooltip ? $style.visible : $style.hidden]"
>
{{ label }}
<N8nText v-if="required" color="primary" :bold="bold" :size="size">*</N8nText>
</N8nText>
<N8nTooltip placement="top" :popper-class="$style.tooltipPopper" :show-after="300">
<N8nIcon icon="question-circle" size="small" />
<template #content>
<div v-n8n-html="addTargetBlank(tooltipText)" />
</template>
</N8nTooltip>
</span>
</div>
<span
v-if="tooltipText && label"
:class="[$style.infoIcon, showTooltip ? $style.visible : $style.hidden]"
>
<N8nTooltip placement="top" :popper-class="$style.tooltipPopper" :show-after="300">
<N8nIcon icon="question-circle" size="small" />
<template #content>
<div v-n8n-html="addTargetBlank(tooltipText)" />
</template>
</N8nTooltip>
</span>
<div :class="$style['trailing-content']">
<div
v-if="$slots.options && label"
:class="{ [$style.overlay]: true, [$style.visible]: showOptions }"
/>
<div
v-if="$slots.options"
:class="{ [$style.options]: true, [$style.visible]: showOptions }"
:data-test-id="`${inputName}-parameter-input-options-container`"
>
<slot name="options" />
</div>
<div
v-if="$slots.issues"
:class="$style.issues"
:data-test-id="`${inputName}-parameter-input-issues-container`"
>
<slot name="issues" />
</div>
</div>
</label>
<div
v-if="$slots.persistentOptions"
class="pl-4xs"
:data-test-id="`${inputName}-parameter-input-persistent-options-container`"
>
<slot name="persistentOptions" />
</div>
<div :class="$style['trailing-content']">
<div
v-if="$slots.options && label"
:class="{ [$style.overlay]: true, [$style.visible]: showOptions }"
/>
<div
v-if="$slots.options"
:class="{ [$style.options]: true, [$style.visible]: showOptions }"
:data-test-id="`${inputName}-parameter-input-options-container`"
>
<slot name="options" />
</div>
<div
v-if="$slots.issues"
:class="$style.issues"
:data-test-id="`${inputName}-parameter-input-issues-container`"
>
<slot name="issues" />
</div>
</div>
</label>
</div>
<slot />
</div>
</template>
@ -116,6 +125,11 @@ const addTargetBlank = (html: string) =>
}
}
.labelRow {
flex-direction: row;
display: flex;
}
.main-content {
display: flex;
&:hover {
@ -140,6 +154,7 @@ const addTargetBlank = (html: string) =>
.inputLabel {
display: block;
flex-grow: 1;
}
.container:hover,
.inputLabel:hover {
@ -234,10 +249,10 @@ const addTargetBlank = (html: string) =>
display: flex;
&.small {
margin-bottom: var(--spacing-5xs);
padding-bottom: var(--spacing-5xs);
}
&.medium {
margin-bottom: var(--spacing-2xs);
padding-bottom: var(--spacing-2xs);
}
}

View file

@ -6,34 +6,39 @@ exports[`component > Text overflow behavior > displays ellipsis with options 1`]
class="container"
data-test-id="input-label"
>
<label
class="n8n-input-label inputLabel heading medium"
<div
class="labelRow"
>
<div
class="main-content"
<label
class="n8n-input-label inputLabel heading medium"
>
<div
class="title"
class="main-content"
>
<span
class="n8n-text size-medium bold textEllipses textEllipses"
<div
class="title"
>
a label
<!--v-if-->
</span>
<span
class="n8n-text size-medium bold textEllipses textEllipses"
>
a label
<!--v-if-->
</span>
</div>
<!--v-if-->
</div>
<!--v-if-->
</div>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<!--v-if-->
</div>
</div>
@ -46,34 +51,39 @@ exports[`component > Text overflow behavior > displays full text without options
class="container"
data-test-id="input-label"
>
<label
class="n8n-input-label inputLabel heading medium"
<div
class="labelRow"
>
<div
class="main-content"
<label
class="n8n-input-label inputLabel heading medium"
>
<div
class="title"
class="main-content"
>
<span
class="n8n-text size-medium bold"
<div
class="title"
>
a label
<!--v-if-->
</span>
<span
class="n8n-text size-medium bold"
>
a label
<!--v-if-->
</span>
</div>
<!--v-if-->
</div>
<!--v-if-->
</div>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<div
class="trailing-content"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<!--v-if-->
</div>
</div>

View file

@ -72,7 +72,7 @@ describe('N8nSelectableList', () => {
expect(wrapper.html()).toMatchSnapshot();
});
it('renders disabled collection and clicks do not modify', async () => {
it('renders disabled collection without selectables', async () => {
const wrapper = render(N8nSelectableList, {
props: {
modelValue: {
@ -87,20 +87,10 @@ describe('N8nSelectableList', () => {
},
});
expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument();
expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument();
expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument();
expect(wrapper.getByTestId('selectable-list-selectable-propC')).toBeInTheDocument();
await fireEvent.click(wrapper.getByTestId('selectable-list-selectable-propA'));
expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument();
expect(wrapper.queryByTestId('selectable-list-slot-propA')).not.toBeInTheDocument();
await fireEvent.click(wrapper.getByTestId('selectable-list-remove-slot-propB'));
expect(wrapper.queryByTestId('selectable-list-selectable-propA')).not.toBeInTheDocument();
expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument();
expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument();
expect(wrapper.queryByTestId('selectable-list-selectable-propC')).not.toBeInTheDocument();
expect(wrapper.html()).toMatchSnapshot();
});

View file

@ -59,7 +59,7 @@ function itemComparator(a: Item, b: Item) {
<template>
<div>
<div :class="$style.selectableContainer">
<div v-if="!disabled" :class="$style.selectableContainer">
<span
v-for="item in visibleSelectables"
:key="item.name"
@ -67,9 +67,11 @@ function itemComparator(a: Item, b: Item) {
:data-test-id="`selectable-list-selectable-${item.name}`"
@click="!props.disabled && addToSelectedItems(item.name)"
>
<slot name="addItem" v-bind="item"
>{{ t('selectableList.addDefault') }} {{ item.name }}</slot
>
<slot name="addItem" v-bind="item">
<div :class="$style.selectableTextSize">
{{ t('selectableList.addDefault') }} {{ item.name }}
</div>
</slot>
</span>
</div>
<div
@ -122,10 +124,15 @@ function itemComparator(a: Item, b: Item) {
cursor: pointer;
:hover {
color: var(--color-primary);
color: var(--text-color-dark);
}
}
.selectableTextSize {
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-loose);
}
.slotRemoveIcon {
color: var(--color-text-light);
height: 10px;

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`N8nSelectableList > renders disabled collection and clicks do not modify 1`] = `
exports[`N8nSelectableList > renders disabled collection without selectables 1`] = `
"<div>
<div class="selectableContainer"><span class="selectableCell" data-test-id="selectable-list-selectable-propA">+ Add a propA</span><span class="selectableCell" data-test-id="selectable-list-selectable-propC">+ Add a propC</span></div>
<!--v-if-->
<div class="slotComboContainer" data-test-id="selectable-list-slot-propB"><span class="n8n-text compact size-xsmall regular n8n-icon slotRemoveIcon slotRemoveIcon n8n-icon slotRemoveIcon slotRemoveIcon" data-test-id="selectable-list-remove-slot-propB"><!----></span>
<div class="slotContainer"></div>
</div>
@ -11,7 +11,7 @@ exports[`N8nSelectableList > renders disabled collection and clicks do not modif
exports[`N8nSelectableList > renders multiple elements with some pre-selected 1`] = `
"<div>
<div class="selectableContainer"><span class="selectableCell" data-test-id="selectable-list-selectable-propB">+ Add a propB</span><span class="selectableCell" data-test-id="selectable-list-selectable-propD">+ Add a propD</span></div>
<div class="selectableContainer"><span class="selectableCell" data-test-id="selectable-list-selectable-propB"><div class="selectableTextSize">+ Add a propB</div></span><span class="selectableCell" data-test-id="selectable-list-selectable-propD"><div class="selectableTextSize">+ Add a propD</div></span></div>
<div class="slotComboContainer" data-test-id="selectable-list-slot-propA"><span class="n8n-text compact size-xsmall regular n8n-icon slotRemoveIcon slotRemoveIcon n8n-icon slotRemoveIcon slotRemoveIcon" data-test-id="selectable-list-remove-slot-propA"><!----></span>
<div class="slotContainer"></div>
</div>

View file

@ -54,7 +54,7 @@ export const mockNodeTypeDescription = ({
credentials = [],
inputs = [NodeConnectionType.Main],
outputs = [NodeConnectionType.Main],
codex = {},
codex = undefined,
properties = [],
}: {
name?: INodeTypeDescription['name'];

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: 'mini' | 'small' | 'medium' | 'large';
}>(),
{
size: 'medium',
},
);
const sizes = {
mini: 8,
small: 10,
medium: 12,
large: 16,
};
</script>
<template>
<svg
:width="sizes[size]"
:height="sizes[size]"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="NodeIcon">
<path
id="Union"
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.7982 7.80784L13.92 7.20243C12.7186 6.80602 12.0148 5.83386 11.6844 4.61226L10.8579 0.586096C10.8363 0.506544 10.7837 0.400024 10.6219 0.400024C10.4857 0.400024 10.4075 0.506544 10.386 0.586096L9.55943 4.61361C9.22773 5.83521 8.52525 6.80737 7.32387 7.20378L5.44562 7.80919C5.18 7.89548 5.17595 8.27032 5.44023 8.36066L7.33196 9.01191C8.52929 9.40968 9.22773 10.3805 9.55943 11.5967L10.3873 15.5784C10.4089 15.6579 10.4534 15.8008 10.6233 15.8008C10.7991 15.8008 10.8362 15.6634 10.858 15.5831L10.8592 15.5784L11.6871 11.5967C12.0188 10.3791 12.7173 9.40833 13.9146 9.01191L15.8063 8.36066C16.0679 8.26897 16.0639 7.89413 15.7982 7.80784ZM5.04114 11.3108C3.92815 10.9434 3.81743 10.5296 3.63184 9.83597L3.62672 9.81687L3.15615 8.16649C3.12784 8.05997 2.85008 8.05997 2.82041 8.16649L2.50085 9.69147C2.31074 10.394 1.90623 10.9522 1.21588 11.18L0.11563 11.6574C-0.0367335 11.7072 -0.0394302 11.923 0.112933 11.9742L1.22127 12.3666C1.90893 12.5945 2.31074 13.1527 2.5022 13.8525L2.82176 15.3114C2.85142 15.4179 3.12784 15.4179 3.15615 15.3114L3.53099 13.8592C3.72111 13.1554 4.01235 12.5958 4.94675 12.3666L5.98768 11.9742C6.14004 11.9216 6.13869 11.7059 5.98498 11.656L5.04114 11.3108ZM5.33019 0.812949C5.36674 0.661849 5.58158 0.659434 5.61894 0.811355L5.61899 0.811582L6.02856 2.50239C6.08442 2.69624 6.23624 2.8465 6.43132 2.89951L7.47286 3.18013C7.61383 3.2197 7.61829 3.41714 7.48035 3.46394L7.48015 3.46401L6.38799 3.83076C6.21241 3.88968 6.07619 4.03027 6.02153 4.20719L5.61894 5.77311L5.61884 5.77349C5.58095 5.92613 5.36829 5.91987 5.33166 5.77336L4.94237 4.21215C4.88888 4.03513 4.75378 3.89336 4.57956 3.83328L3.48805 3.4555C3.34919 3.40591 3.36033 3.20859 3.50031 3.17175L3.50054 3.17169L4.53472 2.90337C4.73486 2.85153 4.89134 2.69755 4.94463 2.49805L5.33019 0.812949Z"
fill="currentColor"
/>
</g>
</svg>
</template>

View file

@ -55,6 +55,12 @@ const workflowsStore = useWorkflowsStore();
const isDragging = computed(() => ndvStore.isDraggableDragging);
function select() {
if (inlineInput.value) {
inlineInput.value.selectAll();
}
}
function focus() {
if (inlineInput.value) {
inlineInput.value.focus();
@ -162,7 +168,7 @@ watch(isDragging, (newIsDragging) => {
onClickOutside(container, (event) => onBlur(event));
defineExpose({ focus });
defineExpose({ focus, select });
</script>
<template>
@ -220,6 +226,7 @@ defineExpose({ focus });
<style lang="scss" module>
.expression-parameter-input {
position: relative;
flex-grow: 1;
:global(.cm-editor) {
background-color: var(--color-code-background);

View file

@ -103,6 +103,11 @@ defineExpose({
focus();
}
},
selectAll: () => {
editorRef.value?.dispatch({
selection: selection.value.extend(0, editorRef.value?.state.doc.length),
});
},
});
</script>

View file

@ -65,6 +65,7 @@ import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from 'n8n-des
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
import { useRouter } from 'vue-router';
import { useElementSize } from '@vueuse/core';
type Picker = { $emit: (arg0: string, arg1: Date) => void };
@ -90,6 +91,7 @@ type Props = {
hideIssues?: boolean;
errorHighlight?: boolean;
isForCredential?: boolean;
canBeOverridden?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
@ -421,17 +423,23 @@ const parameterOptions = computed<INodePropertyOptions[] | undefined>(() => {
return remoteParameterOptions.value;
});
const isSwitch = computed(
() => props.parameter.type === 'boolean' && !isModelValueExpression.value,
);
const isTextarea = computed(
() => props.parameter.type === 'string' && editorRows.value !== undefined,
);
const parameterInputClasses = computed(() => {
const classes: { [c: string]: boolean } = {
const classes: Record<string, boolean> = {
droppable: props.droppable,
activeDrop: props.activeDrop,
};
const rows = editorRows.value;
const isTextarea = props.parameter.type === 'string' && rows !== undefined;
const isSwitch = props.parameter.type === 'boolean' && !isModelValueExpression.value;
if (!isTextarea && !isSwitch) {
if (isSwitch.value) {
classes['parameter-switch'] = true;
} else {
classes['parameter-value-container'] = true;
}
@ -722,6 +730,15 @@ function onResourceLocatorDrop(data: string) {
emit('drop', data);
}
function selectInput() {
const inputRef = inputField.value;
if (inputRef) {
if ('select' in inputRef) {
inputRef.select();
}
}
}
async function setFocus() {
if (['json'].includes(props.parameter.type) && getArgument('alwaysOpenEditWindow')) {
displayEditDialog();
@ -976,6 +993,36 @@ onMounted(() => {
});
});
const { height } = useElementSize(wrapper);
const isSingleLineInput = computed(() => {
if (isTextarea.value && !isModelValueExpression.value) {
return false;
}
/**
* There is an awkward edge case here with text boxes that automatically
* adjust their row count based on their content:
*
* If we move the overrideButton to the options row due to going multiline,
* the text area gains more width and might return to single line.
* This then causes the overrideButton to move inline, creating a loop which results in flickering UI.
*
* To avoid this, we treat 2 rows of input as single line if we were already single line.
*/
if (isSingleLineInput.value) {
return height.value <= 70;
}
return height.value <= 35;
});
defineExpose({
isSingleLineInput,
focusInput: async () => await setFocus(),
selectInput: () => selectInput(),
});
onBeforeUnmount(() => {
props.eventBus.off('optionSelected', optionSelected);
});
@ -1053,7 +1100,16 @@ onUpdated(async () => {
@update:model-value="expressionUpdated"
></ExpressionEditModal>
<div class="parameter-input ignore-key-press-canvas" :style="parameterInputWrapperStyle">
<div
:class="[
'parameter-input',
'ignore-key-press-canvas',
{
[$style.noRightCornersInput]: canBeOverridden,
},
]"
:style="parameterInputWrapperStyle"
>
<ResourceLocator
v-if="parameter.type === 'resourceLocator'"
ref="resourceLocator"
@ -1529,7 +1585,18 @@ onUpdated(async () => {
<InlineExpressionTip />
</div>
</div>
<div
v-if="$slots.overrideButton"
:class="[
$style.overrideButton,
{
[$style.overrideButtonStandalone]: isSwitch,
[$style.overrideButtonInline]: !isSwitch,
},
]"
>
<slot name="overrideButton" />
</div>
<ParameterIssues
v-if="parameter.type !== 'credentialsSelect' && !isResourceLocatorParameter"
:issues="getIssues"
@ -1556,6 +1623,13 @@ onUpdated(async () => {
align-items: center;
}
.parameter-switch {
display: inline-flex;
align-self: flex-start;
justify-items: center;
gap: var(--spacing-xs);
}
.parameter-input {
display: inline-block;
position: relative;
@ -1730,4 +1804,26 @@ onUpdated(async () => {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.noRightCornersInput > * {
--input-border-bottom-right-radius: 0;
--input-border-top-right-radius: 0;
}
.overrideButton {
align-self: start;
}
.overrideButtonStandalone {
position: relative;
/* This is to balance for the extra margin on the switch */
top: -2px;
}
.overrideButtonInline {
> button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
</style>

View file

@ -0,0 +1,158 @@
import { renderComponent } from '@/__tests__/render';
import type { useNDVStore } from '@/stores/ndv.store';
import { createTestingPinia } from '@pinia/testing';
import type { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { useSettingsStore } from '@/stores/settings.store';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
import ParameterInputFull from './ParameterInputFull.vue';
import { FROM_AI_AUTO_GENERATED_MARKER } from 'n8n-workflow';
type Writeable<T> = { -readonly [P in keyof T]: T[P] };
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
let mockNodeTypesState: Writeable<Partial<ReturnType<typeof useNodeTypesStore>>>;
let mockSettingsState: Writeable<Partial<ReturnType<typeof useSettingsStore>>>;
beforeEach(() => {
mockNdvState = {
hasInputData: true,
activeNode: {
id: '123',
name: 'myParam',
parameters: {},
position: [0, 0],
type: 'test',
typeVersion: 1,
},
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
};
mockNodeTypesState = {
allNodeTypes: [],
};
mockSettingsState = {
settings: {
releaseChannel: 'stable',
} as never,
isEnterpriseFeatureEnabled: { externalSecrets: false } as never,
};
createAppModals();
});
vi.mock('@/stores/ndv.store', () => {
return {
useNDVStore: vi.fn(() => mockNdvState),
};
});
vi.mock('@/stores/nodeTypes.store', () => {
return {
useNodeTypesStore: vi.fn(() => mockNodeTypesState),
};
});
vi.mock('@/stores/settings.store', () => {
return {
useSettingsStore: vi.fn(() => mockSettingsState),
};
});
describe('ParameterInputFull.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
it('should render basic parameter', async () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({});
const { getByTestId } = renderComponent(ParameterInputFull, {
pinia: createTestingPinia(),
props: {
path: 'myParam',
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
},
});
expect(getByTestId('parameter-input')).toBeInTheDocument();
});
it('should render parameter with override button inline', async () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
codex: {
categories: ['AI'],
subcategories: { AI: ['Tools'] },
},
});
const { getByTestId } = renderComponent(ParameterInputFull, {
pinia: createTestingPinia(),
props: {
path: 'myParam',
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
value: '',
},
});
expect(getByTestId('parameter-input')).toBeInTheDocument();
expect(getByTestId('from-ai-override-button')).toBeInTheDocument();
});
it('should render parameter with override button in options', async () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
codex: {
categories: ['AI'],
subcategories: { AI: ['Tools'] },
},
});
const { getByTestId } = renderComponent(ParameterInputFull, {
pinia: createTestingPinia(),
props: {
path: 'myParam',
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
value: `={{
'and the air is free'
}}`,
},
});
expect(getByTestId('parameter-input')).toBeInTheDocument();
expect(getByTestId('from-ai-override-button')).toBeInTheDocument();
});
it('should render parameter with active override', async () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
codex: {
categories: ['AI'],
subcategories: { AI: ['Tools'] },
},
});
const { queryByTestId, getByTestId } = renderComponent(ParameterInputFull, {
pinia: createTestingPinia(),
props: {
path: 'myParam',
value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
parameter: {
displayName: 'My Param',
name: 'myParam',
type: 'string',
},
},
});
expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
expect(queryByTestId('override-button')).not.toBeInTheDocument();
});
});

View file

@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, type ComputedRef, ref, useTemplateRef, watch } from 'vue';
import type { IUpdateInformation } from '@/Interface';
import DraggableTarget from '@/components/DraggableTarget.vue';
import ParameterInputWrapper from '@/components/ParameterInputWrapper.vue';
import ParameterOptions from '@/components/ParameterOptions.vue';
import FromAiOverrideButton from '@/components/ParameterInputOverrides/FromAiOverrideButton.vue';
import FromAiOverrideField from '@/components/ParameterInputOverrides/FromAiOverrideField.vue';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useNDVStore } from '@/stores/ndv.store';
@ -12,8 +14,21 @@ import { getMappedResult } from '@/utils/mappingUtils';
import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/utils/nodeTypesUtils';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import { createEventBus } from 'n8n-design-system/utils';
import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow';
import {
type INodeProperties,
type IParameterLabel,
type NodeParameterValueType,
} from 'n8n-workflow';
import { N8nInputLabel } from 'n8n-design-system';
import {
buildValueFromOverride,
type FromAIOverride,
isFromAIOverrideValue,
makeOverrideValue,
updateFromAIOverrideValues,
} from '../utils/fromAIOverrideUtils';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useTelemetry } from '@/composables/useTelemetry';
type Props = {
parameter: INodeProperties;
@ -54,8 +69,28 @@ const menuExpanded = ref(false);
const forceShowExpression = ref(false);
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const telemetry = useTelemetry();
const node = computed(() => ndvStore.activeNode);
const fromAIOverride = ref<FromAIOverride | null>(
makeOverrideValue(
props,
node.value && nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion),
),
);
const canBeContentOverride = computed(() => {
// The resourceLocator handles overrides separately
if (!node.value || isResourceLocator.value) return false;
return fromAIOverride.value !== null;
});
const isContentOverride = computed(
() => canBeContentOverride.value && !!isFromAIOverrideValue(props.value?.toString() ?? ''),
);
const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path));
const isResourceLocator = computed(
@ -69,9 +104,21 @@ const isDropDisabled = computed(
isExpression.value,
);
const isExpression = computed(() => isValueExpression(props.parameter, props.value));
const showExpressionSelector = computed(() =>
isResourceLocator.value ? !hasOnlyListMode(props.parameter) : true,
);
const showExpressionSelector = computed(() => {
if (isResourceLocator.value) {
// The resourceLocator handles overrides itself, so we use this hack to
// infer whether it's overridden and we should hide the toggle
const value =
props.value && typeof props.value === 'object' && 'value' in props.value && props.value.value;
if (value && isFromAIOverrideValue(String(value))) {
return false;
}
return !hasOnlyListMode(props.parameter);
}
return !isContentOverride.value;
});
function onFocus() {
focused.value = true;
@ -98,6 +145,9 @@ function onMenuExpanded(expanded: boolean) {
}
function optionSelected(command: string) {
if (isContentOverride.value && command === 'resetValue') {
removeOverride(true);
}
eventBus.value.emit('optionSelected', command);
}
@ -189,6 +239,10 @@ function onDrop(newParamValue: string) {
}, 200);
}
const showOverrideButton = computed(
() => canBeContentOverride.value && !isContentOverride.value && !props.isReadOnly,
);
// When switching to read-only mode, reset the value to the default value
watch(
() => props.isReadOnly,
@ -199,10 +253,60 @@ watch(
}
},
);
const parameterInputWrapper = useTemplateRef('parameterInputWrapper');
const isSingleLineInput: ComputedRef<boolean> = computed(
() => parameterInputWrapper.value?.isSingleLineInput ?? false,
);
function applyOverride() {
if (!fromAIOverride.value) return;
telemetry.track(
'User turned on fromAI override',
{
nodeType: node.value?.type,
parameter: props.path,
},
{ withPostHog: true },
);
updateFromAIOverrideValues(fromAIOverride.value, String(props.value));
const value = buildValueFromOverride(fromAIOverride.value, props, true);
valueChanged({
node: node.value?.name,
name: props.path,
value,
});
}
function removeOverride(clearField = false) {
if (!fromAIOverride.value) return;
telemetry.track(
'User turned off fromAI override',
{
nodeType: node.value?.type,
parameter: props.path,
},
{ withPostHog: true },
);
valueChanged({
node: node.value?.name,
name: props.path,
value: clearField
? props.parameter.default
: buildValueFromOverride(fromAIOverride.value, props, false),
});
void setTimeout(async () => {
await parameterInputWrapper.value?.focusInput();
parameterInputWrapper.value?.selectInput();
}, 0);
}
</script>
<template>
<N8nInputLabel
ref="inputLabel"
:class="[$style.wrapper]"
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
:tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
@ -213,6 +317,15 @@ watch(
:size="label.size"
color="text-dark"
>
<template
v-if="showOverrideButton && !isSingleLineInput && optionsPosition === 'top'"
#persistentOptions
>
<div :class="[$style.noCornersBottom, $style.overrideButtonInOptions]">
<FromAiOverrideButton @click="applyOverride" />
</div>
</template>
<template v-if="displayOptions && optionsPosition === 'top'" #options>
<ParameterOptions
:parameter="parameter"
@ -232,28 +345,41 @@ watch(
@drop="onDrop"
>
<template #default="{ droppable, activeDrop }">
<ParameterInputWrapper
:parameter="parameter"
:model-value="value"
:path="path"
<FromAiOverrideField
v-if="fromAIOverride && isContentOverride"
:is-read-only="isReadOnly"
:is-assignment="isAssignment"
:rows="rows"
:droppable="droppable"
:active-drop="activeDrop"
:force-show-expression="forceShowExpression"
:hint="hint"
:hide-hint="hideHint"
:hide-issues="hideIssues"
:label="label"
:event-bus="eventBus"
input-size="small"
@update="valueChanged"
@text-input="onTextInput"
@focus="onFocus"
@blur="onBlur"
@drop="onDrop"
@close="removeOverride"
/>
<div v-else>
<ParameterInputWrapper
ref="parameterInputWrapper"
:parameter="parameter"
:model-value="value"
:path="path"
:is-read-only="isReadOnly"
:is-assignment="isAssignment"
:rows="rows"
:droppable="droppable"
:active-drop="activeDrop"
:force-show-expression="forceShowExpression"
:hint="hint"
:hide-hint="hideHint"
:hide-issues="hideIssues"
:label="label"
:event-bus="eventBus"
:can-be-overridden="canBeContentOverride"
input-size="small"
@update="valueChanged"
@text-input="onTextInput"
@focus="onFocus"
@blur="onBlur"
@drop="onDrop"
>
<template v-if="showOverrideButton && isSingleLineInput" #overrideButton>
<FromAiOverrideButton @click="applyOverride" />
</template>
</ParameterInputWrapper>
</div>
</template>
</DraggableTarget>
<div
@ -273,6 +399,14 @@ watch(
@menu-expanded="onMenuExpanded"
/>
</div>
<ParameterOverrideSelectableList
v-if="isContentOverride && fromAIOverride"
v-model="fromAIOverride"
:parameter="parameter"
:path="path"
:is-read-only="isReadOnly"
@update="valueChanged"
/>
</N8nInputLabel>
</template>
@ -287,6 +421,17 @@ watch(
}
}
.overrideButtonInOptions {
position: relative;
// To connect to input panel below the button
margin-bottom: -2px;
}
.noCornersBottom > button {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.options {
position: absolute;
bottom: -22px;

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
const i18n = useI18n();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<N8nTooltip>
<template #content>
<div>{{ i18n.baseText('parameterOverride.applyOverrideButtonTooltip') }}</div>
</template>
<N8nButton
:class="[$style.overrideButton]"
type="tertiary"
data-test-id="from-ai-override-button"
@click="emit('click')"
>
<AiStarsIcon size="large" :class="$style.aiStarsIcon" />
</N8nButton>
</N8nTooltip>
</template>
<style lang="scss" module>
.overrideButton {
display: flex;
justify-content: center;
border: 0px;
height: 30px;
width: 30px;
background-color: var(--color-foreground-base);
color: var(--color-foreground-xdark);
&:hover {
color: var(--color-foreground-xdark);
background-color: var(--color-secondary);
}
}
</style>

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import { i18n } from '@/plugins/i18n';
defineProps<{
isReadOnly?: boolean;
}>();
const emit = defineEmits<{
close: [];
}>();
</script>
<template>
<div :class="$style.contentOverrideContainer" data-test-id="fromAI-override-field">
<div :class="[$style.iconStars, 'el-input-group__prepend', $style.noCornersRight]">
<AiStarsIcon :class="$style.aiStarsIcon" />
</div>
<div :class="['flex-grow', $style.overrideInput]">
<N8nText color="text-dark" size="small">{{
i18n.baseText('parameterOverride.overridePanelText')
}}</N8nText>
<N8nText color="text-dark" size="small" bold>{{
i18n.baseText('parameterOverride.overridePanelTextModel')
}}</N8nText>
</div>
<N8nIconButton
v-if="!isReadOnly"
type="tertiary"
:class="['n8n-input', $style.overrideCloseButton]"
outline="false"
icon="xmark"
size="xsmall"
@click="emit('close')"
/>
</div>
</template>
<style lang="scss" module>
.iconStars {
align-self: center;
padding-left: 8px;
width: 22px;
text-align: center;
border: none;
color: var(--color-foreground-xdark);
background-color: var(--color-foreground-base);
}
.noCornersRight {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.overrideInput {
height: 30px;
align-content: center;
flex-grow: 1;
* > input {
padding-left: 0;
// We need this in light mode
background-color: var(--color-foreground-base) !important;
border: none;
}
}
.overrideCloseButton {
padding: 0px 8px 3px; // the icon used is off-center vertically
border: 0px;
color: var(--color-text-base);
--button-hover-background-color: transparent;
--button-active-background-color: transparent;
}
.contentOverrideContainer {
display: flex;
gap: var(--spacing-4xs);
border-radius: var(--border-radius-base);
background-color: var(--color-foreground-base);
}
</style>

View file

@ -0,0 +1,75 @@
<script setup lang="ts">
import type { IUpdateInformation } from '@/Interface';
import { type INodeProperties } from 'n8n-workflow';
import { buildValueFromOverride, type FromAIOverride } from '../../utils/fromAIOverrideUtils';
import { computed } from 'vue';
import { N8nSelectableList } from 'n8n-design-system';
type Props = {
parameter: INodeProperties;
path: string;
isReadOnly?: boolean;
};
const parameterOverride = defineModel<FromAIOverride>({ required: true });
const props = withDefaults(defineProps<Props>(), {
isReadOnly: false,
});
const inputs = computed(() =>
Object.entries(parameterOverride.value.extraProps).map(([name, prop]) => ({
name,
...prop,
})),
);
function proper(s: string) {
return s[0].toUpperCase() + s.slice(1);
}
const emit = defineEmits<{
update: [value: IUpdateInformation];
}>();
function valueChanged(parameterData: IUpdateInformation) {
emit('update', parameterData);
}
</script>
<template>
<N8nSelectableList
v-model="parameterOverride.extraPropValues"
class="mt-2xs"
:inputs="inputs"
:disabled="isReadOnly"
>
<template #displayItem="{ name, tooltip, initialValue, type, typeOptions }">
<ParameterInputFull
:parameter="{
name,
displayName: proper(name),
type,
default: initialValue,
noDataExpression: true,
description: tooltip,
typeOptions,
}"
:is-read-only="isReadOnly"
:value="parameterOverride?.extraPropValues[name]"
:path="`${path}.${name}`"
input-size="small"
@update="
(x) => {
parameterOverride.extraPropValues[name] = x.value;
valueChanged({
name: props.path,
value: buildValueFromOverride(parameterOverride, props, true),
});
}
"
/>
</template>
</N8nSelectableList>
</template>

View file

@ -18,7 +18,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { isValueExpression, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
import { computed } from 'vue';
import { computed, useTemplateRef } from 'vue';
type Props = {
parameter: INodeProperties;
@ -41,6 +41,7 @@ type Props = {
eventSource?: string;
label?: IParameterLabel;
eventBus?: EventBus;
canBeOverridden?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
@ -151,6 +152,14 @@ function onValueChanged(parameterData: IUpdateInformation) {
function onTextInput(parameterData: IUpdateInformation) {
emit('textInput', parameterData);
}
const param = useTemplateRef('param');
const isSingleLineInput = computed(() => param.value?.isSingleLineInput);
defineExpose({
isSingleLineInput,
focusInput: () => param.value?.focusInput(),
selectInput: () => param.value?.selectInput(),
});
</script>
<template>
@ -177,12 +186,17 @@ function onTextInput(parameterData: IUpdateInformation) {
:rows="rows"
:data-test-id="`parameter-input-${parsedParameterName}`"
:event-bus="eventBus"
:can-be-overridden="canBeOverridden"
@focus="onFocus"
@blur="onBlur"
@drop="onDrop"
@text-input="onTextInput"
@update="onValueChanged"
/>
>
<template #overrideButton>
<slot v-if="$slots.overrideButton" name="overrideButton" />
</template>
</ParameterInput>
<div v-if="!hideHint && (expressionOutput || parameterHint)" :class="$style.hint">
<div>
<InputHint

View file

@ -46,6 +46,13 @@ import { useRouter } from 'vue-router';
import ResourceLocatorDropdown from './ResourceLocatorDropdown.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { onClickOutside, type VueInstance } from '@vueuse/core';
import {
buildValueFromOverride,
isFromAIOverrideValue,
makeOverrideValue,
updateFromAIOverrideValues,
type FromAIOverride,
} from '../../utils/fromAIOverrideUtils';
interface IResourceLocatorQuery {
results: INodeListSearchItems[];
@ -282,6 +289,32 @@ const requiresSearchFilter = computed(
() => !!getPropertyArgument(currentMode.value, 'searchFilterRequired'),
);
const fromAIOverride = ref<FromAIOverride | null>(
makeOverrideValue(
{
value: props.modelValue?.value ?? '',
...props,
},
props.node && nodeTypesStore.getNodeType(props.node.type, props.node.typeVersion),
),
);
const canBeContentOverride = computed(() => {
if (!props.node) return false;
return fromAIOverride.value !== null;
});
const isContentOverride = computed(
() =>
canBeContentOverride.value &&
!!isFromAIOverrideValue(props.modelValue?.value?.toString() ?? ''),
);
const showOverrideButton = computed(
() => canBeContentOverride.value && !isContentOverride.value && !props.isReadOnly,
);
watch(currentQueryError, (curr, prev) => {
if (resourceDropdownVisible.value && curr && !prev) {
if (inputRef.value) {
@ -678,6 +711,46 @@ function onInputBlur(event: FocusEvent) {
}
emit('blur');
}
function applyOverride() {
if (!props.node || !fromAIOverride.value) return;
telemetry.track(
'User turned on fromAI override',
{
nodeType: props.node.type,
parameter: props.path,
},
{ withPostHog: true },
);
updateFromAIOverrideValues(fromAIOverride.value, props.modelValue.value?.toString() ?? '');
emit('update:modelValue', {
...props.modelValue,
value: buildValueFromOverride(fromAIOverride.value, props, true),
});
}
function removeOverride() {
if (!props.node || !fromAIOverride.value) return;
telemetry.track(
'User turned off fromAI override',
{
nodeType: props.node.type,
parameter: props.path,
},
{ withPostHog: true },
);
emit('update:modelValue', {
...props.modelValue,
value: buildValueFromOverride(fromAIOverride.value, props, false),
});
void setTimeout(() => {
inputRef.value?.focus();
inputRef.value?.select();
}, 0);
}
</script>
<template>
@ -723,9 +796,18 @@ function onInputBlur(event: FocusEvent) {
:class="{
[$style.resourceLocator]: true,
[$style.multipleModes]: hasMultipleModes,
[$style.inputContainerInputCorners]:
hasMultipleModes && canBeContentOverride && !isContentOverride,
}"
>
<div :class="$style.background"></div>
<div
:class="[
$style.background,
{
[$style.backgroundOverride]: showOverrideButton,
},
]"
></div>
<div v-if="hasMultipleModes" :class="$style.modeSelector">
<n8n-select
:model-value="selectedMode"
@ -762,16 +844,26 @@ function onInputBlur(event: FocusEvent) {
>
<template #default="{ droppable, activeDrop }">
<div
:class="{
[$style.listModeInputContainer]: isListMode,
[$style.droppable]: droppable,
[$style.activeDrop]: activeDrop,
}"
:class="[
{
[$style.listModeInputContainer]: isListMode,
[$style.droppable]: droppable,
[$style.activeDrop]: activeDrop,
[$style.rightNoCorner]: canBeContentOverride && !isContentOverride,
},
]"
@keydown.stop="onKeyDown"
>
<FromAiOverrideField
v-if="fromAIOverride && isContentOverride"
:class="[$style.inputField, $style.fromAiOverrideField]"
:is-read-only="isReadOnly"
@close="removeOverride"
/>
<ExpressionParameterInput
v-if="isValueExpression || forceShowExpression"
v-else-if="isValueExpression || forceShowExpression"
ref="inputRef"
:class="$style.inputField"
:model-value="expressionDisplayValue"
:path="path"
:rows="3"
@ -781,7 +873,13 @@ function onInputBlur(event: FocusEvent) {
<n8n-input
v-else
ref="inputRef"
:class="{ [$style.selectInput]: isListMode }"
:class="[
$style.inputField,
{
[$style.selectInput]: isListMode,
[$style.rightNoCorner]: canBeContentOverride && !isContentOverride,
},
]"
:size="inputSize"
:model-value="valueToDisplay"
:disabled="isReadOnly"
@ -805,6 +903,9 @@ function onInputBlur(event: FocusEvent) {
/>
</template>
</n8n-input>
<div v-if="showOverrideButton" :class="$style.overrideButtonInline">
<FromAiOverrideButton @click="applyOverride" />
</div>
</div>
</template>
</DraggableTarget>
@ -821,6 +922,14 @@ function onInputBlur(event: FocusEvent) {
</div>
</div>
</ResourceLocatorDropdown>
<ParameterOverrideSelectableList
v-if="isContentOverride && fromAIOverride"
v-model="fromAIOverride"
:parameter="parameter"
:path="path"
:is-read-only="isReadOnly"
@update="(x) => onInputChange(x.value?.toString())"
/>
</div>
</template>

View file

@ -21,12 +21,39 @@ $--mode-selector-width: 92px;
}
}
.inputField {
flex-grow: 1;
position: relative;
}
.fromAiOverrideField {
position: relative;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.overrideButtonInline {
> button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
position: relative;
}
.rightNoCorner > * {
--input-border-bottom-right-radius: 0;
--input-border-top-right-radius: 0;
}
.resourceLocator {
display: flex;
display: inline-flex;
flex-wrap: wrap;
width: 100%;
position: relative;
--input-issues-width: 28px;
--input-override-width: 30px;
.inputContainer {
display: flex;
@ -38,6 +65,10 @@ $--mode-selector-width: 92px;
> div {
width: 100%;
> div {
display: flex;
}
}
}
@ -52,13 +83,19 @@ $--mode-selector-width: 92px;
border-radius: var(--border-radius-base);
}
.backgroundOverride {
right: var(--input-override-width);
}
&.multipleModes {
.inputContainer {
display: flex;
align-items: center;
flex-basis: calc(100% - $--mode-selector-width);
flex-grow: 1;
}
.inputContainerInputCorners {
input {
border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0;
}

View file

@ -2,17 +2,21 @@
exports[`MultipleParameter > should render correctly 1`] = `
"<div data-v-a47e4507="" class="duplicate-parameter">
<div data-v-a47e4507="" class="container" data-test-id="input-label"><label class="n8n-input-label inputLabel heading underline small">
<div class="main-content">
<div class="title"><span class="n8n-text text-dark size-small bold">Additional Fields <!--v-if--></span></div>
<!--v-if-->
</div>
<div class="trailing-content">
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label></div>
<div data-v-a47e4507="" class="container" data-test-id="input-label">
<div class="labelRow"><label class="n8n-input-label inputLabel heading underline small">
<div class="main-content">
<div class="title"><span class="n8n-text text-dark size-small bold">Additional Fields <!--v-if--></span></div>
<!--v-if-->
</div>
<div class="trailing-content">
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
</label>
<!--v-if-->
</div>
</div>
<div data-v-a47e4507="" class="add-item-wrapper">
<div data-v-a47e4507="" class="no-items-exist"><span data-v-a47e4507="" class="n8n-text size-small regular">Currently no items exist</span></div><button data-v-a47e4507="" class="button button tertiary medium block" aria-live="polite">
<!--v-if--><span>Add item</span>

View file

@ -1450,6 +1450,10 @@
"parameterInputList.parameterOptions": "Parameter Options",
"parameterInputList.loadingFields": "Loading fields...",
"parameterInputList.loadingError": "Error loading fields. Refresh you page and try again.",
"parameterOverride.overridePanelText": "Defined automatically by the ",
"parameterOverride.overridePanelTextModel": "model",
"parameterOverride.applyOverrideButtonTooltip": "Let the model define this parameter",
"parameterOverride.descriptionTooltip": "Explain to the LLM how it should generate this value, a good, specific description would allow LLMs to produce expected results much more often",
"personalizationModal.businessOwner": "Business Owner",
"personalizationModal.continue": "Continue",
"personalizationModal.cicd": "CI/CD",

View file

@ -0,0 +1,167 @@
import type { FromAIOverride, OverrideContext } from './fromAIOverrideUtils';
import {
buildValueFromOverride,
fromAIExtraProps,
isFromAIOverrideValue,
makeOverrideValue,
parseOverrides,
} from './fromAIOverrideUtils';
import type { INodeTypeDescription } from 'n8n-workflow';
const DISPLAY_NAME = 'aDisplayName';
const PARAMETER_NAME = 'aName';
const makeContext = (value: string, path?: string): OverrideContext => ({
parameter: {
name: PARAMETER_NAME,
displayName: DISPLAY_NAME,
type: 'string',
},
value,
path: path ?? `parameters.${PARAMETER_NAME}`,
});
const MOCK_NODE_TYPE_MIXIN = {
version: 0,
defaults: {},
inputs: [],
outputs: [],
properties: [],
displayName: '',
group: [],
description: '',
};
const AI_NODE_TYPE: INodeTypeDescription = {
name: 'AN_AI_NODE_TYPE',
codex: {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
},
},
...MOCK_NODE_TYPE_MIXIN,
};
const AI_DENYLIST_NODE_TYPE: INodeTypeDescription = {
name: 'toolCode',
codex: {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
},
},
...MOCK_NODE_TYPE_MIXIN,
};
const NON_AI_NODE_TYPE: INodeTypeDescription = {
name: 'AN_NOT_AI_NODE_TYPE',
...MOCK_NODE_TYPE_MIXIN,
};
describe('makeOverrideValue', () => {
test.each<[string, ...Parameters<typeof makeOverrideValue>]>([
['null nodeType', makeContext(''), null],
['non-ai node type', makeContext(''), NON_AI_NODE_TYPE],
['ai node type on denylist', makeContext(''), AI_DENYLIST_NODE_TYPE],
])('should not create an override for %s', (_name, context, nodeType) => {
expect(makeOverrideValue(context, nodeType)).toBeNull();
});
it('should create an fromAI override', () => {
const result = makeOverrideValue(
makeContext(`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('${DISPLAY_NAME}') }}`),
AI_NODE_TYPE,
);
expect(result).not.toBeNull();
expect(result?.type).toEqual('fromAI');
});
it('parses existing fromAI overrides', () => {
const description = 'a description';
const result = makeOverrideValue(
makeContext(
`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('${DISPLAY_NAME}', \`${description}\`) }}`,
),
AI_NODE_TYPE,
);
expect(result).toBeDefined();
expect(result?.extraPropValues.description).toEqual(description);
});
it('parses an existing fromAI override with default values without adding extraPropValue entry', () => {
const result = makeOverrideValue(
makeContext("={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('aName', ``) }}"),
AI_NODE_TYPE,
);
expect(result).toBeDefined();
expect(result?.extraPropValues.description).not.toBeDefined();
});
});
describe('FromAiOverride', () => {
it('correctly identifies override values', () => {
expect(isFromAIOverrideValue('={{ $fromAI() }}')).toBe(false);
expect(isFromAIOverrideValue('={{ /*n8n-auto-generated-fromAI-override*/ $fromAI() }}')).toBe(
true,
);
});
it('should parseOverrides as expected', () => {
expect(parseOverrides("={{ $fromAI('aKey' }}")).toBeNull();
expect(parseOverrides("={{ $fromAI('aKey') }}")).toEqual({
description: undefined,
});
expect(parseOverrides("={{ $fromAI('aKey', `a description`) }}")).toEqual({
description: 'a description',
});
expect(parseOverrides("={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('aKey') }}")).toEqual(
{ description: undefined },
);
expect(
parseOverrides(
"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('aKey', `a description`) }}",
),
).toEqual({
description: 'a description',
});
});
test.each<[string, string, string]>([
['none', '$fromAI("a", `b`)', 'b'],
['a simple case of', '$fromAI("a", `\\``)', '`'],
// this is a bug in the current implementation
// see related comments in the main file
// We try to use different quote characters where possible
['a complex case of', '$fromAI("a", `a \\` \\\\\\``)', 'a ` `'],
])('should handle %s backtick escaping ', (_name, value, expected) => {
expect(parseOverrides(value)).toEqual({ description: expected });
});
it('should build a value from an override and carry over modification', () => {
const override: FromAIOverride = {
type: 'fromAI',
extraProps: fromAIExtraProps,
extraPropValues: {},
};
expect(buildValueFromOverride(override, makeContext(''), true)).toEqual(
`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('${DISPLAY_NAME}', \`\`, 'string') }}`,
);
expect(buildValueFromOverride(override, makeContext(''), false)).toEqual(
`={{ $fromAI('${DISPLAY_NAME}', \`\`, 'string') }}`,
);
const description = 'a description';
override.extraPropValues.description = description;
expect(buildValueFromOverride(override, makeContext(''), true)).toEqual(
`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('${DISPLAY_NAME}', \`${description}\`, 'string') }}`,
);
expect(buildValueFromOverride(override, makeContext(''), false)).toEqual(
`={{ $fromAI('${DISPLAY_NAME}', \`${description}\`, 'string') }}`,
);
});
});

View file

@ -0,0 +1,190 @@
import {
extractFromAICalls,
FROM_AI_AUTO_GENERATED_MARKER,
type INodeTypeDescription,
type NodeParameterValueType,
type NodePropertyTypes,
} from 'n8n-workflow';
import { i18n } from '@/plugins/i18n';
export type OverrideContext = {
parameter: {
name: string;
displayName: string;
type: NodePropertyTypes;
noDataExpression?: boolean;
typeOptions?: { editor?: string };
};
value: NodeParameterValueType;
path: string;
};
type ExtraPropValue = {
initialValue: string;
tooltip: string;
type: NodePropertyTypes;
typeOptions?: { rows?: number };
};
type FromAIExtraProps = 'description';
export type FromAIOverride = {
type: 'fromAI';
readonly extraProps: Record<FromAIExtraProps, ExtraPropValue>;
extraPropValues: Partial<Record<string, NodeParameterValueType>>;
};
function sanitizeFromAiParameterName(s: string) {
s = s.replace(/[^a-zA-Z0-9\-]/g, '_');
if (s.length >= 64) {
s = s.slice(0, 63);
}
return s;
}
const NODE_DENYLIST = ['toolCode', 'toolHttpRequest'];
const PATH_DENYLIST = [
'parameters.name',
'parameters.description',
// This is used in e.g. the telegram node if the dropdown selects manual mode
'parameters.toolDescription',
];
export const fromAIExtraProps: Record<FromAIExtraProps, ExtraPropValue> = {
description: {
initialValue: '',
type: 'string',
typeOptions: { rows: 2 },
tooltip: i18n.baseText('parameterOverride.descriptionTooltip'),
},
};
function isExtraPropKey(
extraProps: FromAIOverride['extraProps'],
key: PropertyKey,
): key is keyof FromAIOverride['extraProps'] {
return extraProps.hasOwnProperty(key);
}
export function updateFromAIOverrideValues(override: FromAIOverride, expr: string) {
const { extraProps, extraPropValues } = override;
const overrides = parseOverrides(expr);
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (isExtraPropKey(extraProps, key)) {
if (extraProps[key].initialValue === value) {
delete extraPropValues[key];
} else {
extraPropValues[key] = value;
}
}
}
}
}
function fieldTypeToFromAiType(propType: NodePropertyTypes) {
switch (propType) {
case 'boolean':
case 'number':
case 'json':
return propType;
default:
return 'string';
}
}
export function isFromAIOverrideValue(s: string) {
return s.startsWith(`={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI(`);
}
function getBestQuoteChar(description: string) {
if (description.includes('\n')) return '`';
if (!description.includes('`')) return '`';
if (!description.includes('"')) return '"';
return "'";
}
export function buildValueFromOverride(
override: FromAIOverride,
props: Pick<OverrideContext, 'parameter'>,
includeMarker: boolean,
) {
const { extraPropValues, extraProps } = override;
const marker = includeMarker ? `${FROM_AI_AUTO_GENERATED_MARKER} ` : '';
const key = sanitizeFromAiParameterName(props.parameter.displayName);
const description =
extraPropValues?.description?.toString() ?? extraProps.description.initialValue;
// We want to escape these characters here as the generated formula needs to be valid JS code, and we risk
// closing the string prematurely without it.
// If we don't escape \ then <my \' description> as would end up as 'my \\' description' in
// the generated code, causing an unescaped quoteChar to close the string.
// We try to minimize this by looking for an unused quote char first
const quoteChar = getBestQuoteChar(description);
const sanitizedDescription = description
.replaceAll(/\\/g, '\\\\')
.replaceAll(quoteChar, `\\${quoteChar}`);
const type = fieldTypeToFromAiType(props.parameter.type);
return `={{ ${marker}$fromAI('${key}', ${quoteChar}${sanitizedDescription}${quoteChar}, '${type}') }}`;
}
export function parseOverrides(
expression: string,
): Record<keyof FromAIOverride['extraProps'], string | undefined> | null {
try {
// `extractFromAICalls` has different escape semantics from JS strings
// Specifically it makes \ escape any following character.
// So we need to escape our \ so we don't drop them accidentally
// However ` used in the description cause \` to appear here, which would break
// So we take the hit and expose the bug for backticks only, turning \` into `.
const preparedExpression = expression.replace(/\\[^`]/g, '\\\\');
const calls = extractFromAICalls(preparedExpression);
if (calls.length === 1) {
return {
description: calls[0].description,
};
}
} catch (e) {}
return null;
}
export function canBeContentOverride(
props: Pick<OverrideContext, 'path' | 'parameter'>,
nodeType: INodeTypeDescription | null,
) {
if (NODE_DENYLIST.some((x) => nodeType?.name?.endsWith(x) ?? false)) return false;
if (PATH_DENYLIST.includes(props.path)) return false;
const codex = nodeType?.codex;
if (!codex?.categories?.includes('AI') || !codex?.subcategories?.AI?.includes('Tools'))
return false;
return !props.parameter.noDataExpression && 'options' !== props.parameter.type;
}
export function makeOverrideValue(
context: OverrideContext,
nodeType: INodeTypeDescription | null | undefined,
): FromAIOverride | null {
if (!nodeType) return null;
if (canBeContentOverride(context, nodeType)) {
const fromAiOverride: FromAIOverride = {
type: 'fromAI',
extraProps: fromAIExtraProps,
extraPropValues: {},
};
updateFromAIOverrideValues(fromAiOverride, context.value?.toString() ?? '');
return fromAiOverride;
}
return null;
}

View file

@ -99,3 +99,5 @@ export const TRIMMED_TASK_DATA_CONNECTIONS_KEY = '__isTrimmedManualExecutionData
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
export const FREE_AI_CREDITS_ERROR_TYPE = 'free_ai_credits_request_error';
export const FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE = 400;
export const FROM_AI_AUTO_GENERATED_MARKER = '/*n8n-auto-generated-fromAI-override*/';

View file

@ -6,6 +6,7 @@ import {
EXECUTE_WORKFLOW_NODE_TYPE,
FREE_AI_CREDITS_ERROR_TYPE,
FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE,
FROM_AI_AUTO_GENERATED_MARKER,
HTTP_REQUEST_NODE_TYPE,
HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE,
LANGCHAIN_CUSTOM_TOOLS,
@ -525,3 +526,60 @@ export const userInInstanceRanOutOfFreeAiCredits = (runData: IRun): boolean => {
return false;
};
export type FromAICount = {
aiNodeCount: number;
aiToolCount: number;
fromAIOverrideCount: number;
fromAIExpressionCount: number;
};
export function resolveAIMetrics(nodes: INode[], nodeTypes: INodeTypes): FromAICount | {} {
const resolvedNodes = nodes
.map((x) => [x, nodeTypes.getByNameAndVersion(x.type, x.typeVersion)] as const)
.filter((x) => !!x[1]?.description);
const aiNodeCount = resolvedNodes.reduce(
(acc, x) => acc + Number(x[1].description.codex?.categories?.includes('AI')),
0,
);
if (aiNodeCount === 0) return {};
let fromAIOverrideCount = 0;
let fromAIExpressionCount = 0;
const tools = resolvedNodes.filter((node) =>
node[1].description.codex?.subcategories?.AI?.includes('Tools'),
);
for (const [node, _] of tools) {
// FlatMap to support values in resourceLocators
const values = Object.values(node.parameters).flatMap((param) => {
if (param && typeof param === 'object' && 'value' in param) param = param.value;
return typeof param === 'string' ? param : [];
});
// Note that we don't match the i in `fromAI` to support lower case i (though we miss fromai)
const overrides = values.reduce(
(acc, value) => acc + Number(value.startsWith(`={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromA`)),
0,
);
fromAIOverrideCount += overrides;
// check for = to avoid scanning lengthy text fields
// this will re-count overrides
fromAIExpressionCount +=
values.reduce(
(acc, value) => acc + Number(value[0] === '=' && value.includes('$fromA', 2)),
0,
) - overrides;
}
return {
aiNodeCount,
aiToolCount: tools.length,
fromAIOverrideCount,
fromAIExpressionCount,
};
}

View file

@ -10,6 +10,9 @@ describe('extractFromAICalls', () => {
test.each<[string, [unknown, unknown, unknown, unknown]]>([
['$fromAI("a", "b", "string")', ['a', 'b', 'string', undefined]],
['$fromAI("a", "b", "number", 5)', ['a', 'b', 'number', 5]],
['$fromAI("a", "`", "number", 5)', ['a', '`', 'number', 5]],
['$fromAI("a", "\\`", "number", 5)', ['a', '`', 'number', 5]], // this is a bit surprising, but intended
['$fromAI("a", "\\n", "number", 5)', ['a', 'n', 'number', 5]], // this is a bit surprising, but intended
['{{ $fromAI("a", "b", "boolean") }}', ['a', 'b', 'boolean', undefined]],
])('should parse args as expected for %s', (formula, [key, description, type, defaultValue]) => {
expect(extractFromAICalls(formula)).toEqual([

View file

@ -3,7 +3,7 @@ import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
import { STICKY_NODE_TYPE } from '@/Constants';
import { ApplicationError, ExpressionError, NodeApiError } from '@/errors';
import type { IRun, IRunData } from '@/Interfaces';
import type { INode, INodeTypeDescription, IRun, IRunData } from '@/Interfaces';
import { NodeConnectionType, type IWorkflowBase } from '@/Interfaces';
import * as nodeHelpers from '@/NodeHelpers';
import {
@ -12,11 +12,13 @@ import {
generateNodesGraph,
getDomainBase,
getDomainPath,
resolveAIMetrics,
userInInstanceRanOutOfFreeAiCredits,
} from '@/TelemetryHelpers';
import { randomInt } from '@/utils';
import { nodeTypes } from './ExpressionExtensions/Helpers';
import type { NodeTypes } from './NodeTypes';
describe('getDomainBase should return protocol plus domain', () => {
test('in valid URLs', () => {
@ -1541,3 +1543,109 @@ function generateTestWorkflowAndRunData(): { workflow: Partial<IWorkflowBase>; r
return { workflow, runData };
}
describe('makeAIMetrics', () => {
const makeNode = (parameters: object, type: string) =>
({
parameters,
type,
typeVersion: 2.1,
id: '7cb0b373-715c-4a89-8bbb-3f238907bc86',
name: 'a name',
position: [0, 0],
}) as INode;
it('should count applicable nodes and parameters', async () => {
const nodes = [
makeNode(
{
sendTo: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}",
sendTwo: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}",
subject: "={{ $fromAI('Subject', ``, 'string') }}",
},
'n8n-nodes-base.gmailTool',
),
makeNode(
{
subject: "={{ $fromAI('Subject', ``, 'string') }}",
verb: "={{ $fromAI('Verb', ``, 'string') }}",
},
'n8n-nodes-base.gmailTool',
),
makeNode(
{
subject: "'A Subject'",
},
'n8n-nodes-base.gmailTool',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['AI'],
subcategories: { AI: ['Tools'] },
},
} as unknown as INodeTypeDescription,
}),
});
const result = resolveAIMetrics(nodes, nodeTypes);
expect(result).toMatchObject({
aiNodeCount: 3,
aiToolCount: 3,
fromAIOverrideCount: 2,
fromAIExpressionCount: 3,
});
});
it('should not count non-applicable nodes and parameters', async () => {
const nodes = [
makeNode(
{
sendTo: 'someone',
},
'n8n-nodes-base.gmail',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {} as unknown as INodeTypeDescription,
}),
});
const result = resolveAIMetrics(nodes, nodeTypes);
expect(result).toMatchObject({});
});
it('should count ai nodes without tools', async () => {
const nodes = [
makeNode(
{
sendTo: 'someone',
},
'n8n-nodes-base.gmailTool',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['AI'],
},
} as unknown as INodeTypeDescription,
}),
});
const result = resolveAIMetrics(nodes, nodeTypes);
expect(result).toMatchObject({
aiNodeCount: 1,
aiToolCount: 0,
fromAIOverrideCount: 0,
fromAIExpressionCount: 0,
});
});
});