diff --git a/cypress/e2e/6-code-node.cy.ts b/cypress/e2e/6-code-node.cy.ts index 4a987dd6cd..0ed9ba5089 100644 --- a/cypress/e2e/6-code-node.cy.ts +++ b/cypress/e2e/6-code-node.cy.ts @@ -5,29 +5,158 @@ const WorkflowPage = new WorkflowPageClass(); const ndv = new NDV(); describe('Code node', () => { - beforeEach(() => { - WorkflowPage.actions.visit(); - }); + describe('Code editor', () => { + beforeEach(() => { + WorkflowPage.actions.visit(); + WorkflowPage.actions.addInitialNodeToCanvas('Manual'); + WorkflowPage.actions.addNodeToCanvas('Code', true, true); + }); - it('should execute the placeholder in all-items mode successfully', () => { - WorkflowPage.actions.addInitialNodeToCanvas('Manual'); - WorkflowPage.actions.addNodeToCanvas('Code'); - WorkflowPage.actions.openNode('Code'); + it('should show correct placeholders switching modes', () => { + cy.contains("// Loop over input items and add a new field").should('be.visible'); - ndv.actions.execute(); + ndv.getters.parameterInput('mode').click(); + ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item'); - WorkflowPage.getters.successToast().contains('Node executed successfully'); - }); + cy.contains("// Add a new field called 'myNewField'").should('be.visible'); - it('should execute the placeholder in each-item mode successfully', () => { - WorkflowPage.actions.addInitialNodeToCanvas('Manual'); - WorkflowPage.actions.addNodeToCanvas('Code'); - WorkflowPage.actions.openNode('Code'); - ndv.getters.parameterInput('mode').click(); - ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item'); + ndv.getters.parameterInput('mode').click(); + ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for All Items'); + cy.contains("// Loop over input items and add a new field").should('be.visible'); + }) - ndv.actions.execute(); + it('should execute the placeholder successfully in both modes', () => { + ndv.actions.execute(); - WorkflowPage.getters.successToast().contains('Node executed successfully'); + WorkflowPage.getters.successToast().contains('Node executed successfully'); + ndv.getters.parameterInput('mode').click(); + ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item'); + + ndv.actions.execute(); + + WorkflowPage.getters.successToast().contains('Node executed successfully'); + }); + }) + + describe('Ask AI', () => { + it('tab should display based on experiment', () => { + WorkflowPage.actions.visit(); + cy.window().then((win) => { + win.featureFlags.override('011_ask_AI', 'control'); + WorkflowPage.actions.addInitialNodeToCanvas('Manual'); + WorkflowPage.actions.addNodeToCanvas('Code'); + WorkflowPage.actions.openNode('Code'); + + cy.getByTestId('code-node-tab-ai').should('not.exist'); + + ndv.actions.close(); + win.featureFlags.override('011_ask_AI', undefined); + WorkflowPage.actions.openNode('Code'); + cy.getByTestId('code-node-tab-ai').should('not.exist'); + }) + }) + + describe('Enabled', () => { + beforeEach(() => { + WorkflowPage.actions.visit(); + cy.window().then((win) => { + win.featureFlags.override('011_ask_AI', 'gpt3'); + WorkflowPage.actions.addInitialNodeToCanvas('Manual'); + WorkflowPage.actions.addNodeToCanvas('Code', true, true); + }) + }) + + it('tab should exist if experiment selected and be selectable', () => { + cy.getByTestId('code-node-tab-ai').should('exist'); + cy.get('#tab-ask-ai').click(); + cy.contains('Hey AI, generate JavaScript').should('exist'); + }) + + it('generate code button should have correct state & tooltips', () => { + cy.getByTestId('code-node-tab-ai').should('exist'); + cy.get('#tab-ask-ai').click(); + + + cy.getByTestId('ask-ai-cta').should('be.disabled'); + cy.getByTestId('ask-ai-cta').realHover(); + cy.getByTestId('ask-ai-cta-tooltip-no-input-data').should('exist'); + ndv.actions.executePrevious(); + cy.getByTestId('ask-ai-cta').realHover(); + cy.getByTestId('ask-ai-cta-tooltip-no-prompt').should('exist'); + cy.getByTestId('ask-ai-prompt-input') + // Type random 14 character string + .type([...Array(14)].map(() => (Math.random() * 36 | 0).toString(36)).join('')) + + cy.getByTestId('ask-ai-cta').realHover(); + cy.getByTestId('ask-ai-cta-tooltip-prompt-too-short').should('exist'); + + cy.getByTestId('ask-ai-prompt-input') + .clear() + // Type random 15 character string + .type([...Array(15)].map(() => (Math.random() * 36 | 0).toString(36)).join('')) + cy.getByTestId('ask-ai-cta').should('be.enabled'); + + cy.getByTestId('ask-ai-prompt-counter').should('contain.text', '15 / 600'); + }) + + it('should send correct schema and replace code', () => { + const prompt = [...Array(20)].map(() => (Math.random() * 36 | 0).toString(36)).join(''); + cy.get('#tab-ask-ai').click(); + ndv.actions.executePrevious(); + + cy.getByTestId('ask-ai-prompt-input').type(prompt) + + cy.intercept('POST', '/rest/ask-ai', { + statusCode: 200, + body: { + data: { + code: 'console.log("Hello World")', + usage: { + prompt_tokens: 15, + completion_tokens: 15, + total_tokens: 30 + } + }, + } + }).as('ask-ai'); + cy.getByTestId('ask-ai-cta').click(); + cy.wait('@ask-ai') + .its('request.body') + .should('deep.include', { + question: prompt, + model: "gpt-3.5-turbo-16k", + context: { schema: [] } + }); + + cy.contains('Code generation completed').should('be.visible') + cy.getByTestId('code-node-tab-code').should('contain.text', 'console.log("Hello World")'); + cy.get('#tab-code').should('have.class', 'is-active'); + }) + + it('should show error based on status code', () => { + const prompt = [...Array(20)].map(() => (Math.random() * 36 | 0).toString(36)).join(''); + cy.get('#tab-ask-ai').click(); + ndv.actions.executePrevious(); + + cy.getByTestId('ask-ai-prompt-input').type(prompt) + + const handledCodes = [ + { code: 400, message: 'Code generation failed due to an unknown reason' }, + { code: 413, message: 'Your workflow data is too large for AI to process' }, + { code: 429, message: 'We\'ve hit our rate limit with our AI partner' }, + { code: 500, message: 'Code generation failed due to an unknown reason' }, + ] + + handledCodes.forEach(({ code, message }) => { + cy.intercept('POST', '/rest/ask-ai', { + statusCode: code, + status: code, + }).as('ask-ai'); + + cy.getByTestId('ask-ai-cta').click(); + cy.contains(message).should('be.visible') + }) + }) + }) }); }); diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index bde403a0fe..301de78cfe 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -114,7 +114,7 @@ export class NDV extends BasePage { cy.get('body').type('{enter}'); }, executePrevious: () => { - this.getters.executePrevious().click(); + this.getters.executePrevious().click({ force: true }); }, mapDataFromHeader: (col: number, parameterName: string) => { const draggable = `[data-test-id="ndv-input-panel"] [data-test-id="ndv-data-container"] table th:nth-child(${col})`; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 2a2a532944..28dd62a141 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -325,6 +325,9 @@ export class Server extends AbstractServer { banners: { dismissed: [], }, + ai: { + enabled: config.getEnv('ai.enabled'), + }, }; } diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 351b31d853..2c2890412f 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -18,6 +18,7 @@ if (inE2ETests) { N8N_PUBLIC_API_DISABLED: 'true', EXTERNAL_FRONTEND_HOOKS_URLS: '', N8N_PERSONALIZATION_ENABLED: 'false', + N8N_AI_ENABLED: 'true', }; } else if (inTest) { const testsDir = join(tmpdir(), 'n8n-tests/'); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 74e4ae67ef..d24fd38c16 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -1174,4 +1174,13 @@ export const schema = { }, }, }, + + ai: { + enabled: { + doc: 'Whether AI features are enabled', + format: Boolean, + default: false, + env: 'N8N_AI_ENABLED', + }, + }, }; diff --git a/packages/design-system/src/components/N8nCircleLoader/CircleLoader.stories.ts b/packages/design-system/src/components/N8nCircleLoader/CircleLoader.stories.ts new file mode 100644 index 0000000000..a9f11a3165 --- /dev/null +++ b/packages/design-system/src/components/N8nCircleLoader/CircleLoader.stories.ts @@ -0,0 +1,50 @@ +import N8nCircleLoader from './CircleLoader.vue'; +import type { StoryFn } from '@storybook/vue3'; + +export default { + title: 'Atoms/CircleLoader', + component: N8nCircleLoader, + argTypes: { + radius: { + control: { + type: 'number', + }, + }, + progress: { + control: { + type: 'number', + }, + }, + strokeWidth: { + control: { + type: 'number', + }, + }, + }, +}; + +interface Args { + radius: number; + progress: number; + strokeWidth: number; +} + +const template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + N8nCircleLoader, + }, + template: ` +
+ +
+ `, +}); + +export const defaultCircleLoader = template.bind({}); +defaultCircleLoader.args = { + radius: 20, + progress: 42, + strokeWidth: 10, +}; diff --git a/packages/design-system/src/components/N8nCircleLoader/CircleLoader.vue b/packages/design-system/src/components/N8nCircleLoader/CircleLoader.vue new file mode 100644 index 0000000000..a9231c2852 --- /dev/null +++ b/packages/design-system/src/components/N8nCircleLoader/CircleLoader.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/packages/design-system/src/components/N8nCircleLoader/__tests__/CircleLoader.spec.ts b/packages/design-system/src/components/N8nCircleLoader/__tests__/CircleLoader.spec.ts new file mode 100644 index 0000000000..a05b4c219a --- /dev/null +++ b/packages/design-system/src/components/N8nCircleLoader/__tests__/CircleLoader.spec.ts @@ -0,0 +1,15 @@ +import { render } from '@testing-library/vue'; +import N8NCircleLoader from '../CircleLoader.vue'; + +describe('N8NCircleLoader', () => { + it('should render correctly', () => { + const wrapper = render(N8NCircleLoader, { + props: { + radius: 20, + progress: 42, + strokeWidth: 10, + }, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/packages/design-system/src/components/N8nCircleLoader/__tests__/__snapshots__/CircleLoader.spec.ts.snap b/packages/design-system/src/components/N8nCircleLoader/__tests__/__snapshots__/CircleLoader.spec.ts.snap new file mode 100644 index 0000000000..1f208ed67d --- /dev/null +++ b/packages/design-system/src/components/N8nCircleLoader/__tests__/__snapshots__/CircleLoader.spec.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`N8NCircleLoader > should render correctly 1`] = ` +"
+ + +
" +`; diff --git a/packages/design-system/src/components/N8nCircleLoader/index.ts b/packages/design-system/src/components/N8nCircleLoader/index.ts new file mode 100644 index 0000000000..fcd1764c53 --- /dev/null +++ b/packages/design-system/src/components/N8nCircleLoader/index.ts @@ -0,0 +1,3 @@ +import N8nCircleLoader from './CircleLoader.vue'; + +export default N8nCircleLoader; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 888149da2c..9e9cbd616e 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -9,6 +9,7 @@ export { default as N8nButton } from './N8nButton'; export { default as N8nCallout } from './N8nCallout'; export { default as N8nCard } from './N8nCard'; export { default as N8nCheckbox } from './N8nCheckbox'; +export { default as N8nCircleLoader } from './N8nCircleLoader'; export { default as N8nColorPicker } from './N8nColorPicker'; export { default as N8nDatatable } from './N8nDatatable'; export { default as N8nFormBox } from './N8nFormBox'; diff --git a/packages/design-system/src/plugin.ts b/packages/design-system/src/plugin.ts index 1ad81241cc..2f0bea9017 100644 --- a/packages/design-system/src/plugin.ts +++ b/packages/design-system/src/plugin.ts @@ -12,6 +12,7 @@ import { N8nCallout, N8nCard, N8nCheckbox, + N8nCircleLoader, N8nColorPicker, N8nDatatable, N8nFormBox, @@ -66,6 +67,7 @@ export const N8nPlugin: Plugin<{}> = { app.component('n8n-callout', N8nCallout); app.component('n8n-card', N8nCard); app.component('n8n-checkbox', N8nCheckbox); + app.component('n8n-circle-loader', N8nCircleLoader); app.component('n8n-color-picker', N8nColorPicker); app.component('n8n-datatable', N8nDatatable); app.component('n8n-form-box', N8nFormBox); diff --git a/packages/editor-ui/src/api/ai.ts b/packages/editor-ui/src/api/ai.ts new file mode 100644 index 0000000000..95ace358fb --- /dev/null +++ b/packages/editor-ui/src/api/ai.ts @@ -0,0 +1,34 @@ +import type { IRestApiContext, Schema } from '@/Interface'; +import { makeRestApiRequest } from '@/utils/apiUtils'; +import type { IDataObject } from 'n8n-workflow'; + +type Usage = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +}; + +export async function generateCodeForPrompt( + ctx: IRestApiContext, + { + question, + context, + model, + n8nVersion, + }: { + question: string; + context: { + schema: Array<{ nodeName: string; schema: Schema }>; + inputSchema: { nodeName: string; schema: Schema }; + }; + model: string; + n8nVersion: string; + }, +): Promise<{ code: string; usage: Usage }> { + return makeRestApiRequest(ctx, 'POST', '/ask-ai', { + question, + context, + model, + n8nVersion, + } as IDataObject); +} diff --git a/packages/editor-ui/src/components/AskAiModal.vue b/packages/editor-ui/src/components/AskAiModal.vue deleted file mode 100644 index 5a606964e4..0000000000 --- a/packages/editor-ui/src/components/AskAiModal.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/CodeNodeEditor/AskAI/AskAI.vue b/packages/editor-ui/src/components/CodeNodeEditor/AskAI/AskAI.vue new file mode 100644 index 0000000000..9a4876b33b --- /dev/null +++ b/packages/editor-ui/src/components/CodeNodeEditor/AskAI/AskAI.vue @@ -0,0 +1,392 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 2a25169fb1..14401cd10f 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -5,24 +5,46 @@ @mouseout="onMouseOut" ref="codeNodeEditorContainer" > -
- - {{ $locale.baseText('codeNodeEditor.askAi') }} - + +
+ + + + + + + +
+ +