mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import ButtonParameter, { type Props } from '@/components/ButtonParameter/ButtonParameter.vue';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
vi.mock('@/stores/ndv.store');
|
||||
vi.mock('@/stores/workflows.store');
|
||||
vi.mock('@/stores/posthog.store');
|
||||
vi.mock('@/stores/root.store');
|
||||
vi.mock('@/api/ai');
|
||||
vi.mock('@/composables/useI18n');
|
||||
vi.mock('@/composables/useToast');
|
||||
|
||||
describe('ButtonParameter', () => {
|
||||
const defaultProps: Props = {
|
||||
parameter: {
|
||||
name: 'testParam',
|
||||
displayName: 'Test Parameter',
|
||||
type: 'string',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
buttonConfig: {
|
||||
label: 'Generate',
|
||||
action: {
|
||||
type: 'askAiCodeGeneration',
|
||||
target: 'targetParam',
|
||||
},
|
||||
hasInputField: true,
|
||||
},
|
||||
},
|
||||
} as INodeProperties,
|
||||
value: '',
|
||||
isReadOnly: false,
|
||||
path: 'testPath',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useNDVStore).mockReturnValue({
|
||||
ndvInputData: [{}],
|
||||
activeNode: { name: 'TestNode', parameters: {} },
|
||||
isDraggableDragging: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useWorkflowsStore).mockReturnValue({
|
||||
getCurrentWorkflow: vi.fn().mockReturnValue({
|
||||
getParentNodesByDepth: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getNodeByName: vi.fn().mockReturnValue({}),
|
||||
} as any);
|
||||
|
||||
vi.mocked(usePostHog).mockReturnValue({
|
||||
isAiEnabled: vi.fn().mockReturnValue(true),
|
||||
getVariant: vi.fn().mockReturnValue('gpt-3.5-turbo-16k'),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useRootStore).mockReturnValue({
|
||||
versionCli: '1.0.0',
|
||||
pushRef: 'testPushRef',
|
||||
} as any);
|
||||
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
baseText: vi.fn().mockReturnValue('Mocked Text'),
|
||||
nodeText: () => ({
|
||||
inputLabelDisplayName: vi.fn().mockReturnValue('Mocked Display Name'),
|
||||
inputLabelDescription: vi.fn().mockReturnValue('Mocked Description'),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useToast).mockReturnValue({
|
||||
showMessage: vi.fn(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
const mountComponent = (props: Partial<Props> = {}) => {
|
||||
return mount(ButtonParameter, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('renders correctly', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('textarea').exists()).toBe(true);
|
||||
expect(wrapper.find('button').text()).toBe('Generate');
|
||||
});
|
||||
|
||||
it('emits valueChanged event on input', async () => {
|
||||
const wrapper = mountComponent();
|
||||
const input = wrapper.find('textarea');
|
||||
await input.setValue('Test prompt');
|
||||
expect(wrapper.emitted('valueChanged')).toBeTruthy();
|
||||
expect(wrapper.emitted('valueChanged')![0][0]).toEqual({
|
||||
name: 'testPath.testParam',
|
||||
value: 'Test prompt',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables submit button when there is no execution data', async () => {
|
||||
vi.mocked(useNDVStore).mockReturnValue({
|
||||
ndvInputData: [],
|
||||
} as any);
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('disables submit button when prompt is empty', async () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('enables submit button when there is execution data and prompt', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await wrapper.find('textarea').setValue('Test prompt');
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls onSubmit when button is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await wrapper.find('textarea').setValue('Test prompt');
|
||||
|
||||
const submitButton = wrapper.find('button');
|
||||
expect(submitButton.attributes('disabled')).toBeUndefined();
|
||||
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect(useToast().showMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables input and button when in read only mode', async () => {
|
||||
const wrapper = mountComponent({ isReadOnly: true });
|
||||
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { type INodeProperties, type NodePropertyAction } from 'n8n-workflow';
|
||||
import type { INodeUi, IUpdateInformation } from '@/Interface';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { N8nButton, N8nInput, N8nTooltip } from '@n8n/design-system/components';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import {
|
||||
getParentNodes,
|
||||
generateCodeForAiTransform,
|
||||
type TextareaRowData,
|
||||
getUpdatedTextareaValue,
|
||||
getTextareaCursorPosition,
|
||||
} from './utils';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
import { propertyNameFromExpression } from '../../utils/mappingUtils';
|
||||
|
||||
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
|
||||
|
||||
const emit = defineEmits<{
|
||||
valueChanged: [value: IUpdateInformation];
|
||||
}>();
|
||||
|
||||
export type Props = {
|
||||
parameter: INodeProperties;
|
||||
value: string;
|
||||
path: string;
|
||||
isReadOnly?: boolean;
|
||||
};
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { activeNode } = useNDVStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const prompt = ref(props.value);
|
||||
const parentNodes = ref<INodeUi[]>([]);
|
||||
const textareaRowsData = ref<TextareaRowData | null>(null);
|
||||
|
||||
const hasExecutionData = computed(() => (useNDVStore().ndvInputData || []).length > 0);
|
||||
const hasInputField = computed(() => props.parameter.typeOptions?.buttonConfig?.hasInputField);
|
||||
const inputFieldMaxLength = computed(
|
||||
() => props.parameter.typeOptions?.buttonConfig?.inputFieldMaxLength,
|
||||
);
|
||||
const buttonLabel = computed(
|
||||
() => props.parameter.typeOptions?.buttonConfig?.label ?? props.parameter.displayName,
|
||||
);
|
||||
const isSubmitEnabled = computed(() => {
|
||||
if (!hasExecutionData.value || !prompt.value || props.isReadOnly) return false;
|
||||
|
||||
const maxlength = inputFieldMaxLength.value;
|
||||
if (maxlength && prompt.value.length > maxlength) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
const promptUpdated = computed(() => {
|
||||
const lastPrompt = activeNode?.parameters[AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT] as string;
|
||||
if (!lastPrompt) return false;
|
||||
return lastPrompt.trim() !== prompt.value.trim();
|
||||
});
|
||||
|
||||
function startLoading() {
|
||||
isLoading.value = true;
|
||||
}
|
||||
|
||||
function stopLoading() {
|
||||
setTimeout(() => {
|
||||
isLoading.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function getPath(parameter: string) {
|
||||
return (props.path ? `${props.path}.` : '') + parameter;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const { showMessage } = useToast();
|
||||
const action: string | NodePropertyAction | undefined =
|
||||
props.parameter.typeOptions?.buttonConfig?.action;
|
||||
|
||||
if (!action || !activeNode) return;
|
||||
|
||||
if (typeof action === 'string') {
|
||||
switch (action) {
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
emit('valueChanged', {
|
||||
name: getPath(props.parameter.name),
|
||||
value: prompt.value,
|
||||
});
|
||||
|
||||
const { type, target } = action;
|
||||
|
||||
startLoading();
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'askAiCodeGeneration':
|
||||
const updateInformation = await generateCodeForAiTransform(
|
||||
prompt.value,
|
||||
getPath(target as string),
|
||||
5,
|
||||
);
|
||||
if (!updateInformation) return;
|
||||
|
||||
//updade code parameter
|
||||
emit('valueChanged', updateInformation);
|
||||
|
||||
//update code generated for prompt parameter
|
||||
emit('valueChanged', {
|
||||
name: getPath(AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT),
|
||||
value: prompt.value,
|
||||
});
|
||||
|
||||
useTelemetry().trackAiTransform('generationFinished', {
|
||||
prompt: prompt.value,
|
||||
code: updateInformation.value,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('codeNodeEditor.askAi.generationCompleted'),
|
||||
});
|
||||
|
||||
stopLoading();
|
||||
} catch (error) {
|
||||
useTelemetry().trackAiTransform('generationFinished', {
|
||||
prompt: prompt.value,
|
||||
code: '',
|
||||
hasError: true,
|
||||
});
|
||||
showMessage({
|
||||
type: 'error',
|
||||
title: i18n.baseText('codeNodeEditor.askAi.generationFailed'),
|
||||
message: error.message,
|
||||
});
|
||||
stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function onPromptInput(inputValue: string) {
|
||||
prompt.value = inputValue;
|
||||
emit('valueChanged', {
|
||||
name: getPath(props.parameter.name),
|
||||
value: inputValue,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
parentNodes.value = getParentNodes();
|
||||
});
|
||||
|
||||
function cleanTextareaRowsData() {
|
||||
textareaRowsData.value = null;
|
||||
}
|
||||
|
||||
async function onDrop(value: string, event: MouseEvent) {
|
||||
value = propertyNameFromExpression(value);
|
||||
|
||||
prompt.value = getUpdatedTextareaValue(event, textareaRowsData.value, value);
|
||||
|
||||
emit('valueChanged', {
|
||||
name: getPath(props.parameter.name),
|
||||
value: prompt.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCursorPositionOnMouseMove(event: MouseEvent, activeDrop: boolean) {
|
||||
if (!activeDrop) return;
|
||||
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
|
||||
const position = getTextareaCursorPosition(
|
||||
textarea,
|
||||
textareaRowsData.value,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(position, position);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n8n-input-label
|
||||
v-if="hasInputField"
|
||||
:label="i18n.nodeText().inputLabelDisplayName(parameter, path)"
|
||||
:tooltip-text="i18n.nodeText().inputLabelDescription(parameter, path)"
|
||||
:bold="false"
|
||||
size="small"
|
||||
color="text-dark"
|
||||
>
|
||||
</n8n-input-label>
|
||||
<div
|
||||
:class="[$style.inputContainer, { [$style.disabled]: isReadOnly }]"
|
||||
:hidden="!hasInputField"
|
||||
>
|
||||
<div :class="$style.meta">
|
||||
<span
|
||||
v-if="inputFieldMaxLength"
|
||||
v-show="prompt.length > 1"
|
||||
:class="$style.counter"
|
||||
v-text="`${prompt.length} / ${inputFieldMaxLength}`"
|
||||
/>
|
||||
<span
|
||||
v-if="promptUpdated"
|
||||
:class="$style['warning-text']"
|
||||
v-text="'Instructions changed'"
|
||||
/>
|
||||
</div>
|
||||
<DraggableTarget type="mapping" :disabled="isLoading" @drop="onDrop">
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<N8nInput
|
||||
v-model="prompt"
|
||||
:class="[
|
||||
$style.input,
|
||||
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||
]"
|
||||
style="border: 1.5px solid var(--color-foreground-base)"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:maxlength="inputFieldMaxLength"
|
||||
:placeholder="parameter.placeholder"
|
||||
:disabled="isReadOnly"
|
||||
@input="onPromptInput"
|
||||
@mousemove="updateCursorPositionOnMouseMove($event, activeDrop)"
|
||||
@mouseleave="cleanTextareaRowsData"
|
||||
/>
|
||||
</template>
|
||||
</DraggableTarget>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<N8nTooltip :disabled="isSubmitEnabled">
|
||||
<div>
|
||||
<N8nButton
|
||||
:disabled="!isSubmitEnabled"
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
type="secondary"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<template #content>
|
||||
<span
|
||||
v-if="!hasExecutionData"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.noInputData')"
|
||||
/>
|
||||
<span
|
||||
v-else-if="prompt.length === 0"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.noPrompt')"
|
||||
/>
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.input * {
|
||||
border: 1.5px transparent !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
.input textarea {
|
||||
font-size: var(--font-size-2xs);
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
font-family: var(--font-family);
|
||||
resize: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
padding: var(--spacing-2xs) 0 0;
|
||||
}
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
padding-bottom: var(--spacing-2xs);
|
||||
padding-top: var(--spacing-2xs);
|
||||
bottom: 2px;
|
||||
left: var(--spacing-xs);
|
||||
right: var(--spacing-xs);
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: end;
|
||||
z-index: 1;
|
||||
background-color: var(--color-foreground-xlight);
|
||||
|
||||
* {
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
.counter {
|
||||
color: var(--color-text-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.controls {
|
||||
padding: var(--spacing-2xs) 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.warning-text {
|
||||
color: var(--color-warning);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.droppable {
|
||||
border: 1.5px dashed var(--color-ndv-droppable-parameter) !important;
|
||||
}
|
||||
.activeDrop {
|
||||
border: 1.5px solid var(--color-success) !important;
|
||||
cursor: grabbing;
|
||||
}
|
||||
.disabled {
|
||||
.meta {
|
||||
background-color: var(--fill-disabled);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { generateCodeForAiTransform, reducePayloadSizeOrThrow } from './utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { generateCodeForPrompt } from '@/api/ai';
|
||||
import type { AskAiRequest } from '@/types/assistant.types';
|
||||
import type { Schema } from '@/Interface';
|
||||
|
||||
vi.mock('./utils', async () => {
|
||||
const actual = await vi.importActual('./utils');
|
||||
return {
|
||||
...actual,
|
||||
getSchemas: vi.fn(() => ({
|
||||
parentNodesSchemas: { test: 'parentSchema' },
|
||||
inputSchema: { test: 'inputSchema' },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/stores/root.store', () => ({
|
||||
useRootStore: () => ({
|
||||
pushRef: 'mockRootPushRef',
|
||||
restApiContext: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/ndv.store', () => ({
|
||||
useNDVStore: () => ({
|
||||
pushRef: 'mockNdvPushRef',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
|
||||
}));
|
||||
|
||||
vi.mock('prettier', () => ({
|
||||
format: vi.fn(async (code) => await Promise.resolve(`formatted-${code}`)),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/ai', () => ({
|
||||
generateCodeForPrompt: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('generateCodeForAiTransform - Retry Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
it('should retry and succeed on the second attempt', async () => {
|
||||
const mockGeneratedCode = 'const example = "retry success";';
|
||||
|
||||
vi.mocked(generateCodeForPrompt)
|
||||
.mockRejectedValueOnce(new Error('First attempt failed'))
|
||||
.mockResolvedValueOnce({ code: mockGeneratedCode });
|
||||
|
||||
const result = await generateCodeForAiTransform('test prompt', 'test/path', 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test/path',
|
||||
value: 'formatted-const example = "retry success";',
|
||||
});
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should exhaust retries and throw an error', async () => {
|
||||
vi.mocked(generateCodeForPrompt).mockRejectedValue(new Error('All attempts failed'));
|
||||
|
||||
await expect(generateCodeForAiTransform('test prompt', 'test/path', 3)).rejects.toThrow(
|
||||
'All attempts failed',
|
||||
);
|
||||
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should succeed on the first attempt without retries', async () => {
|
||||
const mockGeneratedCode = 'const example = "no retries needed";';
|
||||
vi.mocked(generateCodeForPrompt).mockResolvedValue({ code: mockGeneratedCode });
|
||||
|
||||
const result = await generateCodeForAiTransform('test prompt', 'test/path');
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test/path',
|
||||
value: 'formatted-const example = "no retries needed";',
|
||||
});
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
const mockPayload = () =>
|
||||
({
|
||||
context: {
|
||||
schema: [
|
||||
{ nodeName: 'node1', data: 'some data' },
|
||||
{ nodeName: 'node2', data: 'other data' },
|
||||
],
|
||||
inputSchema: {
|
||||
schema: {
|
||||
value: [
|
||||
{ key: 'prop1', value: 'value1' },
|
||||
{ key: 'prop2', value: 'value2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
question: 'What is node1 and prop1?',
|
||||
}) as unknown as AskAiRequest.RequestPayload;
|
||||
|
||||
describe('reducePayloadSizeOrThrow', () => {
|
||||
it('reduces schema size when tokens exceed the limit', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Limit is 100 tokens, but 104 were provided');
|
||||
|
||||
reducePayloadSizeOrThrow(payload, error);
|
||||
|
||||
expect(payload.context.schema.length).toBe(1);
|
||||
expect(payload.context.schema[0]).toEqual({ nodeName: 'node1', data: 'some data' });
|
||||
});
|
||||
|
||||
it('removes unreferenced properties in input schema', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Limit is 100 tokens, but 150 were provided');
|
||||
|
||||
reducePayloadSizeOrThrow(payload, error);
|
||||
|
||||
expect(payload.context.inputSchema.schema.value.length).toBe(1);
|
||||
expect((payload.context.inputSchema.schema.value as Schema[])[0].key).toBe('prop1');
|
||||
});
|
||||
|
||||
it('removes all parent nodes if needed', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Limit is 100 tokens, but 150 were provided');
|
||||
|
||||
payload.question = '';
|
||||
|
||||
reducePayloadSizeOrThrow(payload, error);
|
||||
|
||||
expect(payload.context.schema.length).toBe(0);
|
||||
});
|
||||
|
||||
it('throws error if tokens still exceed after reductions', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Limit is 100 tokens, but 200 were provided');
|
||||
|
||||
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
|
||||
});
|
||||
|
||||
it('throws error if message format is invalid', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Invalid token message format');
|
||||
|
||||
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { Schema } from '@/Interface';
|
||||
import { ApplicationError, type INodeExecutionData } from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useDataSchema } from '@/composables/useDataSchema';
|
||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||
import { generateCodeForPrompt } from '@/api/ai';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { type AskAiRequest } from '@/types/assistant.types';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { format } from 'prettier';
|
||||
import jsParser from 'prettier/plugins/babel';
|
||||
import * as estree from 'prettier/plugins/estree';
|
||||
|
||||
export type TextareaRowData = {
|
||||
rows: string[];
|
||||
linesToRowsMap: number[][];
|
||||
};
|
||||
|
||||
export function getParentNodes() {
|
||||
const activeNode = useNDVStore().activeNode;
|
||||
const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore();
|
||||
const workflow = getCurrentWorkflow();
|
||||
|
||||
if (!activeNode || !workflow) return [];
|
||||
|
||||
return workflow
|
||||
.getParentNodesByDepth(activeNode?.name)
|
||||
.filter(({ name }, i, nodes) => {
|
||||
return name !== activeNode.name && nodes.findIndex((node) => node.name === name) === i;
|
||||
})
|
||||
.map((n) => getNodeByName(n.name))
|
||||
.filter((n) => n !== null);
|
||||
}
|
||||
|
||||
export function getSchemas() {
|
||||
const parentNodes = getParentNodes();
|
||||
const parentNodesNames = parentNodes.map((node) => node?.name);
|
||||
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
|
||||
const parentNodesSchemas: Array<{ nodeName: string; schema: Schema }> = parentNodes
|
||||
.map((node) => {
|
||||
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
|
||||
|
||||
return {
|
||||
nodeName: node?.name || '',
|
||||
schema: getSchemaForExecutionData(executionDataToJson(inputData), false),
|
||||
};
|
||||
})
|
||||
.filter((node) => node.schema?.value.length > 0);
|
||||
|
||||
const inputSchema = parentNodesSchemas.shift();
|
||||
|
||||
return {
|
||||
parentNodesNames,
|
||||
inputSchema,
|
||||
parentNodesSchemas,
|
||||
};
|
||||
}
|
||||
|
||||
//------ Reduce payload ------
|
||||
|
||||
const estimateNumberOfTokens = (item: unknown, averageTokenLength: number): number => {
|
||||
if (typeof item === 'object') {
|
||||
return Math.ceil(JSON.stringify(item).length / averageTokenLength);
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const calculateRemainingTokens = (error: Error) => {
|
||||
// Expected message format:
|
||||
//'This model's maximum context length is 8192 tokens. However, your messages resulted in 10514 tokens.'
|
||||
const tokens = error.message.match(/\d+/g);
|
||||
|
||||
if (!tokens || tokens.length < 2) throw error;
|
||||
|
||||
const maxTokens = parseInt(tokens[0], 10);
|
||||
const currentTokens = parseInt(tokens[1], 10);
|
||||
|
||||
return currentTokens - maxTokens;
|
||||
};
|
||||
|
||||
const trimParentNodesSchema = (
|
||||
payload: AskAiRequest.RequestPayload,
|
||||
remainingTokensToReduce: number,
|
||||
averageTokenLength: number,
|
||||
) => {
|
||||
//check if parent nodes schema takes more tokens than available
|
||||
let parentNodesTokenCount = estimateNumberOfTokens(payload.context.schema, averageTokenLength);
|
||||
|
||||
if (remainingTokensToReduce > parentNodesTokenCount) {
|
||||
remainingTokensToReduce -= parentNodesTokenCount;
|
||||
payload.context.schema = [];
|
||||
}
|
||||
|
||||
//remove parent nodes not referenced in the prompt
|
||||
if (payload.context.schema.length) {
|
||||
const nodes = [...payload.context.schema];
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) {
|
||||
if (payload.question.includes(nodes[nodeIndex].nodeName)) continue;
|
||||
|
||||
const nodeTokens = estimateNumberOfTokens(nodes[nodeIndex], averageTokenLength);
|
||||
remainingTokensToReduce -= nodeTokens;
|
||||
parentNodesTokenCount -= nodeTokens;
|
||||
payload.context.schema.splice(nodeIndex, 1);
|
||||
|
||||
if (remainingTokensToReduce <= 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
return [remainingTokensToReduce, parentNodesTokenCount];
|
||||
};
|
||||
|
||||
const trimInputSchemaProperties = (
|
||||
payload: AskAiRequest.RequestPayload,
|
||||
remainingTokensToReduce: number,
|
||||
averageTokenLength: number,
|
||||
parentNodesTokenCount: number,
|
||||
) => {
|
||||
if (remainingTokensToReduce <= 0) return remainingTokensToReduce;
|
||||
|
||||
//remove properties not referenced in the prompt from the input schema
|
||||
if (Array.isArray(payload.context.inputSchema.schema.value)) {
|
||||
const props = [...payload.context.inputSchema.schema.value];
|
||||
|
||||
for (let index = 0; index < props.length; index++) {
|
||||
const key = props[index].key;
|
||||
|
||||
if (key && payload.question.includes(key)) continue;
|
||||
|
||||
const propTokens = estimateNumberOfTokens(props[index], averageTokenLength);
|
||||
remainingTokensToReduce -= propTokens;
|
||||
payload.context.inputSchema.schema.value.splice(index, 1);
|
||||
|
||||
if (remainingTokensToReduce <= 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
//if tokensToReduce is still remaining, remove all parent nodes
|
||||
if (remainingTokensToReduce > 0) {
|
||||
payload.context.schema = [];
|
||||
remainingTokensToReduce -= parentNodesTokenCount;
|
||||
}
|
||||
|
||||
return remainingTokensToReduce;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to reduce the size of the payload to fit within token limits or throws an error if unsuccessful,
|
||||
* payload would be modified in place
|
||||
*
|
||||
* @param {AskAiRequest.RequestPayload} payload - The request payload to be trimmed,
|
||||
* 'schema' and 'inputSchema.schema' will be modified.
|
||||
* @param {Error} error - The error to throw if the token reduction fails.
|
||||
* @param {number} [averageTokenLength=4] - The average token length used for estimation.
|
||||
* @throws {Error} - Throws the provided error if the payload cannot be reduced sufficiently.
|
||||
*/
|
||||
export function reducePayloadSizeOrThrow(
|
||||
payload: AskAiRequest.RequestPayload,
|
||||
error: Error,
|
||||
averageTokenLength = 4,
|
||||
) {
|
||||
let remainingTokensToReduce = calculateRemainingTokens(error);
|
||||
|
||||
const [remaining, parentNodesTokenCount] = trimParentNodesSchema(
|
||||
payload,
|
||||
remainingTokensToReduce,
|
||||
averageTokenLength,
|
||||
);
|
||||
|
||||
remainingTokensToReduce = remaining;
|
||||
|
||||
remainingTokensToReduce = trimInputSchemaProperties(
|
||||
payload,
|
||||
remainingTokensToReduce,
|
||||
averageTokenLength,
|
||||
parentNodesTokenCount,
|
||||
);
|
||||
|
||||
if (remainingTokensToReduce > 0) throw error;
|
||||
}
|
||||
|
||||
export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) {
|
||||
const schemas = getSchemas();
|
||||
|
||||
const payload: AskAiRequest.RequestPayload = {
|
||||
question: prompt,
|
||||
context: {
|
||||
schema: schemas.parentNodesSchemas,
|
||||
inputSchema: schemas.inputSchema!,
|
||||
ndvPushRef: useNDVStore().pushRef,
|
||||
pushRef: useRootStore().pushRef,
|
||||
},
|
||||
forNode: 'transform',
|
||||
};
|
||||
|
||||
let value;
|
||||
if (useSettingsStore().isAskAiEnabled) {
|
||||
const { restApiContext } = useRootStore();
|
||||
|
||||
let code = '';
|
||||
|
||||
while (retries > 0) {
|
||||
try {
|
||||
const { code: generatedCode } = await generateCodeForPrompt(restApiContext, payload);
|
||||
code = generatedCode;
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.message.includes('maximum context length')) {
|
||||
reducePayloadSizeOrThrow(payload, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
retries--;
|
||||
if (!retries) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
value = code;
|
||||
} else {
|
||||
throw new ApplicationError('AI code generation is not enabled');
|
||||
}
|
||||
|
||||
if (value === undefined) return;
|
||||
|
||||
const formattedCode = await format(String(value), {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser, estree],
|
||||
});
|
||||
|
||||
const updateInformation = {
|
||||
name: path,
|
||||
value: formattedCode,
|
||||
};
|
||||
|
||||
return updateInformation;
|
||||
}
|
||||
|
||||
//------ drag and drop ------
|
||||
|
||||
function splitText(textarea: HTMLTextAreaElement, textareaRowsData: TextareaRowData | null) {
|
||||
if (textareaRowsData) return textareaRowsData;
|
||||
const rows: string[] = [];
|
||||
const linesToRowsMap: number[][] = [];
|
||||
const style = window.getComputedStyle(textarea);
|
||||
|
||||
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const border = parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
|
||||
const textareaWidth = textarea.clientWidth - padding - border;
|
||||
|
||||
const context = createTextContext(style);
|
||||
|
||||
const lines = textarea.value.split('\n');
|
||||
|
||||
lines.forEach((_) => {
|
||||
linesToRowsMap.push([]);
|
||||
});
|
||||
lines.forEach((line, index) => {
|
||||
if (line === '') {
|
||||
rows.push(line);
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
return;
|
||||
}
|
||||
let currentLine = '';
|
||||
const words = line.split(/(\s+)/);
|
||||
|
||||
words.forEach((word) => {
|
||||
const testLine = currentLine + word;
|
||||
const testWidth = context.measureText(testLine).width;
|
||||
|
||||
if (testWidth <= textareaWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
rows.push(currentLine.trimEnd());
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
currentLine = word;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentLine) {
|
||||
rows.push(currentLine.trimEnd());
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
return { rows, linesToRowsMap };
|
||||
}
|
||||
|
||||
function createTextContext(style: CSSStyleDeclaration): CanvasRenderingContext2D {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||||
return context;
|
||||
}
|
||||
|
||||
const getRowIndex = (textareaY: number, lineHeight: string) => {
|
||||
const rowHeight = parseInt(lineHeight, 10);
|
||||
const snapPosition = textareaY - rowHeight / 2 - 1;
|
||||
return Math.floor(snapPosition / rowHeight);
|
||||
};
|
||||
|
||||
const getColumnIndex = (rowText: string, textareaX: number, font: string) => {
|
||||
const span = document.createElement('span');
|
||||
span.style.font = font;
|
||||
span.style.visibility = 'hidden';
|
||||
span.style.position = 'absolute';
|
||||
span.style.whiteSpace = 'pre';
|
||||
document.body.appendChild(span);
|
||||
|
||||
let left = 0;
|
||||
let right = rowText.length;
|
||||
let col = 0;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
span.textContent = rowText.substring(0, mid);
|
||||
const width = span.getBoundingClientRect().width;
|
||||
|
||||
if (width <= textareaX) {
|
||||
col = mid;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
document.body.removeChild(span);
|
||||
|
||||
return rowText.length === col ? col : col - 1;
|
||||
};
|
||||
|
||||
export function getUpdatedTextareaValue(
|
||||
event: MouseEvent,
|
||||
textareaRowsData: TextareaRowData | null,
|
||||
value: string,
|
||||
) {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const textareaX = event.clientX - rect.left;
|
||||
const textareaY = event.clientY - rect.top;
|
||||
const { lineHeight, font } = window.getComputedStyle(textarea);
|
||||
|
||||
const rowIndex = getRowIndex(textareaY, lineHeight);
|
||||
|
||||
const rowsData = splitText(textarea, textareaRowsData);
|
||||
|
||||
let newText = value;
|
||||
|
||||
if (rowsData.rows[rowIndex] === undefined) {
|
||||
newText = `${textarea.value} ${value}`;
|
||||
}
|
||||
const { rows, linesToRowsMap } = rowsData;
|
||||
const rowText = rows[rowIndex];
|
||||
|
||||
if (rowText === '') {
|
||||
rows[rowIndex] = value;
|
||||
} else {
|
||||
const col = getColumnIndex(rowText, textareaX, font);
|
||||
rows[rowIndex] = [rows[rowIndex].slice(0, col).trim(), value, rows[rowIndex].slice(col).trim()]
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
newText = linesToRowsMap
|
||||
.map((lineMap) => {
|
||||
return lineMap.map((index) => rows[index]).join(' ');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return newText;
|
||||
}
|
||||
|
||||
export function getTextareaCursorPosition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
textareaRowsData: TextareaRowData | null,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) {
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const textareaX = clientX - rect.left;
|
||||
const textareaY = clientY - rect.top;
|
||||
const { lineHeight, font } = window.getComputedStyle(textarea);
|
||||
|
||||
const rowIndex = getRowIndex(textareaY, lineHeight);
|
||||
const { rows } = splitText(textarea, textareaRowsData);
|
||||
|
||||
if (rowIndex < 0 || rowIndex >= rows.length) {
|
||||
return textarea.value.length;
|
||||
}
|
||||
|
||||
const rowText = rows[rowIndex];
|
||||
|
||||
const col = getColumnIndex(rowText, textareaX, font);
|
||||
|
||||
const position = rows.slice(0, rowIndex).reduce((acc, curr) => acc + curr.length + 1, 0) + col;
|
||||
|
||||
return position;
|
||||
}
|
||||
Reference in New Issue
Block a user