diff --git a/cypress/e2e/27-cloud.cy.ts b/cypress/e2e/27-cloud.cy.ts index e9b814597d..131c549963 100644 --- a/cypress/e2e/27-cloud.cy.ts +++ b/cypress/e2e/27-cloud.cy.ts @@ -5,11 +5,15 @@ import { WorkflowPage, visitPublicApiPage, getPublicApiUpgradeCTA, + WorkflowsPage, } from '../pages'; +const NUMBER_OF_AI_CREDITS = 100; + const mainSidebar = new MainSidebar(); const bannerStack = new BannerStack(); const workflowPage = new WorkflowPage(); +const workflowsPage = new WorkflowsPage(); describe('Cloud', () => { before(() => { @@ -22,6 +26,10 @@ describe('Cloud', () => { cy.overrideSettings({ deployment: { type: 'cloud' }, n8nMetadata: { userId: '1' }, + aiCredits: { + enabled: true, + credits: NUMBER_OF_AI_CREDITS, + }, }); cy.intercept('GET', '/rest/admin/cloud-plan', planData).as('getPlanData'); cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo'); @@ -64,4 +72,66 @@ describe('Cloud', () => { getPublicApiUpgradeCTA().should('be.visible'); }); }); + + describe('Easy AI workflow experiment', () => { + it('should not show option to take you to the easy AI workflow if experiment is control', () => { + window.localStorage.setItem( + 'N8N_EXPERIMENT_OVERRIDES', + JSON.stringify({ '026_easy_ai_workflow': 'control' }), + ); + + cy.visit(workflowsPage.url); + + cy.getByTestId('easy-ai-workflow-card').should('not.exist'); + }); + + it('should show option to take you to the easy AI workflow if experiment is variant', () => { + window.localStorage.setItem( + 'N8N_EXPERIMENT_OVERRIDES', + JSON.stringify({ '026_easy_ai_workflow': 'variant' }), + ); + + cy.visit(workflowsPage.url); + + cy.getByTestId('easy-ai-workflow-card').should('to.exist'); + }); + + it('should show default instructions if free AI credits experiment is control', () => { + window.localStorage.setItem( + 'N8N_EXPERIMENT_OVERRIDES', + JSON.stringify({ '027_free_openai_calls': 'control', '026_easy_ai_workflow': 'variant' }), + ); + + cy.visit(workflowsPage.url); + + cy.getByTestId('easy-ai-workflow-card').click(); + + workflowPage.getters + .stickies() + .eq(0) + .should(($el) => { + expect($el).contains.text('Set up your OpenAI credentials in the OpenAI Model node'); + }); + }); + + it('should show updated instructions if free AI credits experiment is variant', () => { + window.localStorage.setItem( + 'N8N_EXPERIMENT_OVERRIDES', + JSON.stringify({ '027_free_openai_calls': 'variant', '026_easy_ai_workflow': 'variant' }), + ); + + cy.visit(workflowsPage.url); + + cy.getByTestId('easy-ai-workflow-card').click(); + + workflowPage.getters + .stickies() + .eq(0) + .should(($el) => { + expect($el).contains.text( + `Claim your free ${NUMBER_OF_AI_CREDITS} OpenAI calls in the OpenAI model node`, + ); + }); + }); + }); }); diff --git a/packages/editor-ui/src/composables/usePushConnection.ts b/packages/editor-ui/src/composables/usePushConnection.ts index e3b976a8ac..4ab211c984 100644 --- a/packages/editor-ui/src/composables/usePushConnection.ts +++ b/packages/editor-ui/src/composables/usePushConnection.ts @@ -18,7 +18,7 @@ import type { PushMessage } from '@n8n/api-types'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useToast } from '@/composables/useToast'; -import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants'; +import { AI_CREDITS_EXPERIMENT, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants'; import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils'; import { codeNodeEditorEventBus, globalLinkActionsEventBus } from '@/event-bus'; import { useUIStore } from '@/stores/ui.store'; @@ -36,8 +36,9 @@ import type { PushMessageQueueItem } from '@/types'; import { useAssistantStore } from '@/stores/assistant.store'; import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue'; import type { IExecutionResponse } from '@/Interface'; -import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows'; import { clearPopupWindowState } from '../utils/executionUtils'; +import { usePostHog } from '@/stores/posthog.store'; +import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils'; export function usePushConnection({ router }: { router: ReturnType }) { const workflowHelpers = useWorkflowHelpers({ router }); @@ -54,6 +55,7 @@ export function usePushConnection({ router }: { router: ReturnType(null); const pushMessageQueue = ref([]); @@ -205,8 +207,13 @@ export function usePushConnection({ router }: { router: ReturnType { + it('should update sticky note content for AI free credits experiment', () => { + const workflow = getEasyAiWorkflowJson({ + isInstanceInAiFreeCreditsExperiment: true, + withOpenAiFreeCredits: 25, + }); + + if (!workflow?.nodes) fail(); + + const stickyNote = workflow.nodes.find( + (node) => node.type === 'n8n-nodes-base.stickyNote' && node.name === 'Sticky Note', + ); + + expect(stickyNote?.parameters.content).toContain( + 'Claim your `free` 25 OpenAI calls in the `OpenAI model` node', + ); + }); + + it('should show default content when not in AI free credits experiment', () => { + const workflow = getEasyAiWorkflowJson({ + isInstanceInAiFreeCreditsExperiment: false, + withOpenAiFreeCredits: 0, + }); + + if (!workflow?.nodes) fail(); + + const stickyNote = workflow.nodes.find( + (node) => node.type === 'n8n-nodes-base.stickyNote' && node.name === 'Sticky Note', + ); + + expect(stickyNote?.parameters.content).toContain( + 'Set up your [OpenAI credentials](https://docs.n8n.io/integrations/builtin/credentials/openai/?utm_source=n8n_app&utm_medium=credential_settings&utm_campaign=create_new_credentials_modal) in the `OpenAI Model` node', + ); + }); +}); diff --git a/packages/editor-ui/src/utils/easyAiWorkflowUtils.ts b/packages/editor-ui/src/utils/easyAiWorkflowUtils.ts new file mode 100644 index 0000000000..3d9fad7809 --- /dev/null +++ b/packages/editor-ui/src/utils/easyAiWorkflowUtils.ts @@ -0,0 +1,193 @@ +import type { INodeUi, WorkflowDataWithTemplateId } from '@/Interface'; +import { NodeConnectionType } from 'n8n-workflow'; + +/** + * Generates a workflow JSON object for an AI Agent in n8n. + * + * @param {Object} params - The parameters for generating the workflow JSON. + * @param {boolean} params.isInstanceInAiFreeCreditsExperiment - Indicates if the instance is part of the AI free credits experiment. + * @param {number} params.withOpenAiFreeCredits - The number of free OpenAI calls available. + * + * @remarks + * This function can be deleted once the free AI credits experiment is removed. + */ +export const getEasyAiWorkflowJson = ({ + isInstanceInAiFreeCreditsExperiment, + withOpenAiFreeCredits, +}: { + withOpenAiFreeCredits: number; + isInstanceInAiFreeCreditsExperiment: boolean; +}): WorkflowDataWithTemplateId => { + let instructionsFirstStep = + 'Set up your [OpenAI credentials](https://docs.n8n.io/integrations/builtin/credentials/openai/?utm_source=n8n_app&utm_medium=credential_settings&utm_campaign=create_new_credentials_modal) in the `OpenAI Model` node'; + + if (isInstanceInAiFreeCreditsExperiment) { + instructionsFirstStep = `Claim your \`free\` ${withOpenAiFreeCredits} OpenAI calls in the \`OpenAI model\` node`; + } + + return { + name: 'Demo: My first AI Agent in n8n', + meta: { + templateId: 'PT1i+zU92Ii5O2XCObkhfHJR5h9rNJTpiCIkYJk9jHU=', + }, + nodes: [ + { + id: '0d7e4666-bc0e-489a-9e8f-a5ef191f4954', + name: 'Google Calendar', + type: 'n8n-nodes-base.googleCalendarTool', + typeVersion: 1.2, + position: [880, 220], + parameters: { + operation: 'getAll', + calendar: { + __rl: true, + mode: 'list', + }, + returnAll: true, + options: { + timeMin: + "={{ $fromAI('after', 'The earliest datetime we want to look for events for') }}", + timeMax: + "={{ $fromAI('before', 'The latest datetime we want to look for events for') }}", + query: + "={{ $fromAI('query', 'The search query to look for in the calendar. Leave empty if no search query is needed') }}", + singleEvents: true, + }, + }, + }, + { + id: '5b410409-5b0b-47bd-b413-5b9b1000a063', + name: 'When chat message received', + type: '@n8n/n8n-nodes-langchain.chatTrigger', + typeVersion: 1.1, + position: [360, 20], + webhookId: 'a889d2ae-2159-402f-b326-5f61e90f602e', + parameters: { + options: {}, + }, + }, + { + id: '29963449-1dc1-487d-96f2-7ff0a5c3cd97', + name: 'AI Agent', + type: '@n8n/n8n-nodes-langchain.agent', + typeVersion: 1.7, + position: [560, 20], + parameters: { + options: { + systemMessage: + "=You're a helpful assistant that the user to answer questions about their calendar.\n\nToday is {{ $now.format('cccc') }} the {{ $now.format('yyyy-MM-dd HH:mm') }}.", + }, + }, + }, + { + id: 'eae35513-07c2-4de2-a795-a153b6934c1b', + name: 'Sticky Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [0, 0], + parameters: { + content: `## 👋 Welcome to n8n!\nThis example shows how to build an AI Agent that interacts with your \ncalendar.\n\n### 1. Connect your accounts\n- ${instructionsFirstStep} \n- Connect your Google account in the \`Google Calendar\` node credentials section\n\n### 2. Ready to test it?\nClick Chat below and start asking questions! For example you can try \`What meetings do I have today?\``, + height: 389, + width: 319, + color: 6, + }, + }, + { + id: '68b59889-7aca-49fd-a49b-d86fa6239b96', + name: 'Sticky Note1', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [820, 200], + parameters: { + content: + "\n\n\n\n\n\n\n\n\n\n\n\nDon't have **Google Calendar**? Simply exchange this with the **Microsoft Outlook** or other tools", + height: 253, + width: 226, + color: 7, + }, + }, + { + id: 'cbaedf86-9153-4778-b893-a7e50d3e04ba', + name: 'OpenAI Model', + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1, + position: [520, 220], + parameters: { + options: {}, + }, + }, + { + id: '75481370-bade-4d90-a878-3a3b0201edcc', + name: 'Memory', + type: '@n8n/n8n-nodes-langchain.memoryBufferWindow', + typeVersion: 1.3, + position: [680, 220], + parameters: {}, + }, + { + id: '907552eb-6e0f-472e-9d90-4513a67a31db', + name: 'Sticky Note3', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [0, 400], + parameters: { + content: + '### Want to learn more?\nWant to learn more about AI and how to apply it best in n8n? Have a look at our [new tutorial series on YouTube](https://www.youtube.com/watch?v=yzvLfHb0nqE&lc).', + height: 100, + width: 317, + color: 6, + }, + }, + ] as INodeUi[], + connections: { + 'Google Calendar': { + ai_tool: [ + [ + { + node: 'AI Agent', + type: NodeConnectionType.AiTool, + index: 0, + }, + ], + ], + }, + 'When chat message received': { + main: [ + [ + { + node: 'AI Agent', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + 'OpenAI Model': { + ai_languageModel: [ + [ + { + node: 'AI Agent', + type: NodeConnectionType.AiLanguageModel, + index: 0, + }, + ], + ], + }, + Memory: { + ai_memory: [ + [ + { + node: 'AI Agent', + type: NodeConnectionType.AiMemory, + index: 0, + }, + ], + ], + }, + }, + settings: { + executionOrder: 'v1', + }, + pinData: {}, + }; +}; diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 865adf80b0..ee3fc857c1 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -62,6 +62,7 @@ import { STICKY_NODE_TYPE, VALID_WORKFLOW_IMPORT_URL_REGEX, VIEWS, + AI_CREDITS_EXPERIMENT, } from '@/constants'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; @@ -85,6 +86,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useTelemetry } from '@/composables/useTelemetry'; import { useHistoryStore } from '@/stores/history.store'; import { useProjectsStore } from '@/stores/projects.store'; +import { usePostHog } from '@/stores/posthog.store'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useExecutionDebugging } from '@/composables/useExecutionDebugging'; import { useUsersStore } from '@/stores/users.store'; @@ -108,7 +110,7 @@ import { getResourcePermissions } from '@/permissions'; import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue'; import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2'; import { isValidNodeConnectionType } from '@/utils/typeGuards'; -import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows'; +import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils'; const LazyNodeCreation = defineAsyncComponent( async () => await import('@/components/Node/NodeCreation.vue'), @@ -155,6 +157,7 @@ const tagsStore = useTagsStore(); const pushConnectionStore = usePushConnectionStore(); const ndvStore = useNDVStore(); const templatesStore = useTemplatesStore(); +const posthogStore = usePostHog(); const canvasEventBus = createEventBus(); @@ -325,7 +328,14 @@ async function initializeRoute(force = false) { const loadWorkflowFromJSON = route.query.fromJson === 'true'; if (loadWorkflowFromJSON) { - await openTemplateFromWorkflowJSON(EASY_AI_WORKFLOW_JSON); + const isAiCreditsExperimentEnabled = + posthogStore.getVariant(AI_CREDITS_EXPERIMENT.name) === AI_CREDITS_EXPERIMENT.variant; + + const easyAiWorkflowJson = getEasyAiWorkflowJson({ + isInstanceInAiFreeCreditsExperiment: isAiCreditsExperimentEnabled, + withOpenAiFreeCredits: settingsStore.aiCreditsQuota, + }); + await openTemplateFromWorkflowJSON(easyAiWorkflowJson); } else { await openWorkflowTemplate(templateId.toString()); } diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 8dac152e5e..c199761a16 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -181,7 +181,8 @@ import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; import { getResourcePermissions } from '@/permissions'; import { useBeforeUnload } from '@/composables/useBeforeUnload'; import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue'; -import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows'; +import { AI_CREDITS_EXPERIMENT } from '@/constants'; +import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils'; interface AddNodeOptions { position?: XYPosition; @@ -220,6 +221,7 @@ export default defineComponent({ const router = useRouter(); const route = useRoute(); + const posthogStore = usePostHog(); const ndvStore = useNDVStore(); const externalHooks = useExternalHooks(); const i18n = useI18n(); @@ -254,6 +256,7 @@ export default defineComponent({ nodeViewRef, onMouseMoveEnd, workflowHelpers, + posthogStore, runWorkflow, stopCurrentExecution, callDebounced, @@ -3401,7 +3404,15 @@ export default defineComponent({ const templateId = this.$route.params.id; const loadWorkflowFromJSON = this.$route.query.fromJson === 'true'; if (loadWorkflowFromJSON) { - await this.openWorkflowTemplateFromJson({ workflow: EASY_AI_WORKFLOW_JSON }); + const isAiCreditsExperimentEnabled = + this.posthogStore.getVariant(AI_CREDITS_EXPERIMENT.name) === + AI_CREDITS_EXPERIMENT.variant; + + const easyAiWorkflowJson = getEasyAiWorkflowJson({ + isInstanceInAiFreeCreditsExperiment: isAiCreditsExperimentEnabled, + withOpenAiFreeCredits: useSettingsStore().aiCreditsQuota, + }); + await this.openWorkflowTemplateFromJson({ workflow: easyAiWorkflowJson }); } else { await this.openWorkflowTemplate(templateId.toString()); } diff --git a/packages/editor-ui/src/views/WorkflowsView.vue b/packages/editor-ui/src/views/WorkflowsView.vue index 253946751f..1d5c3514d8 100644 --- a/packages/editor-ui/src/views/WorkflowsView.vue +++ b/packages/editor-ui/src/views/WorkflowsView.vue @@ -6,7 +6,12 @@ import ResourcesListLayout, { } from '@/components/layouts/ResourcesListLayout.vue'; import WorkflowCard from '@/components/WorkflowCard.vue'; import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue'; -import { EASY_AI_WORKFLOW_EXPERIMENT, EnterpriseEditionFeature, VIEWS } from '@/constants'; +import { + EASY_AI_WORKFLOW_EXPERIMENT, + AI_CREDITS_EXPERIMENT, + EnterpriseEditionFeature, + VIEWS, +} from '@/constants'; import type { IUser, IWorkflowDb } from '@/Interface'; import { useUIStore } from '@/stores/ui.store'; import { useSettingsStore } from '@/stores/settings.store'; @@ -32,7 +37,7 @@ import { } from 'n8n-design-system'; import { pickBy } from 'lodash-es'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; -import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows'; +import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils'; const i18n = useI18n(); const route = useRoute(); @@ -269,9 +274,18 @@ const openAIWorkflow = async (source: string) => { }, { withPostHog: true }, ); + + const isAiCreditsExperimentEnabled = + posthogStore.getVariant(AI_CREDITS_EXPERIMENT.name) === AI_CREDITS_EXPERIMENT.variant; + + const easyAiWorkflowJson = getEasyAiWorkflowJson({ + isInstanceInAiFreeCreditsExperiment: isAiCreditsExperimentEnabled, + withOpenAiFreeCredits: settingsStore.aiCreditsQuota, + }); + await router.push({ name: VIEWS.TEMPLATE_IMPORT, - params: { id: EASY_AI_WORKFLOW_JSON.meta.templateId }, + params: { id: easyAiWorkflowJson.meta.templateId }, query: { fromJson: 'true' }, }); };