diff --git a/ee/app/assets/javascripts/ai/settings/components/duo_core_features_form.vue b/ee/app/assets/javascripts/ai/settings/components/duo_core_features_form.vue index 9bea39dc5a6307343a3b3980c72109e3f47489eb..e149fcbc331a774eb765476c6978758ef2a82da6 100644 --- a/ee/app/assets/javascripts/ai/settings/components/duo_core_features_form.vue +++ b/ee/app/assets/javascripts/ai/settings/components/duo_core_features_form.vue @@ -10,6 +10,7 @@ import { import { s__, __ } from '~/locale'; import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; import PromoPageLink from '~/vue_shared/components/promo_page_link/promo_page_link.vue'; +import DuoCoreWithSelfHostedModal from './duo_core_with_self_hosted_modal.vue'; export default { name: 'DuoCoreFeaturesForm', @@ -33,11 +34,12 @@ export default { GlLink, GlSprintf, PromoPageLink, + DuoCoreWithSelfHostedModal, }, directives: { tooltip: GlTooltipDirective, }, - inject: ['isSaaS'], + inject: ['isSaaS', 'canManageSelfHostedModels', 'hasOnlineLicense'], props: { disabledCheckbox: { type: Boolean, @@ -54,6 +56,22 @@ export default { }; }, computed: { + isOnlineDuoSelfHosted() { + return this.canManageSelfHostedModels && this.hasOnlineLicense; + }, + checkboxDisabled() { + if (this.isDisabledForSelfHostedModels()) return true; + + return this.disabledCheckbox; + }, + disabledTooltipText() { + if (this.isDisabledForSelfHostedModels()) + return s__('AiPowered|This setting requires access to the GitLab.com AI gateway.'); + + return s__( + 'AiPowered|This setting requires GitLab Duo availability to be on or off by default.', + ); + }, description() { return this.isSaaS ? this.$options.i18n.checkboxHelpTextSaaS @@ -61,8 +79,24 @@ export default { }, }, methods: { - checkboxChanged() { - this.$emit('change', this.duoCoreEnabled); + isDisabledForSelfHostedModels() { + return Boolean(this.canManageSelfHostedModels && !this.hasOnlineLicense); + }, + toggleDuoCore(value) { + this.$emit('change', value); + this.duoCoreEnabled = value; + }, + checkboxChanged(value) { + if (this.isOnlineDuoSelfHosted && value) { + this.toggleDuoCore(false); + this.$refs.DuoCoreWithSelfHostedModal.show(); + return; + } + + this.toggleDuoCore(value); + }, + enableDuoCore() { + this.toggleDuoCore(true); }, }, requirementsPath: `${DOCS_URL}/subscriptions/subscription-add-ons#gitlab-duo-core`, @@ -76,25 +110,27 @@ export default { :label-description="$options.i18n.subtitle" class="gl-my-4" > +
{{ $options.i18n.checkboxLabel }} diff --git a/ee/app/assets/javascripts/ai/settings/components/duo_core_with_self_hosted_modal.vue b/ee/app/assets/javascripts/ai/settings/components/duo_core_with_self_hosted_modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..6eb54757b8e6ca41f87c8ca01db2e9d6ef3b2e3e --- /dev/null +++ b/ee/app/assets/javascripts/ai/settings/components/duo_core_with_self_hosted_modal.vue @@ -0,0 +1,75 @@ + + diff --git a/ee/app/assets/javascripts/ai/settings/index.js b/ee/app/assets/javascripts/ai/settings/index.js index eb77f51db32fff8bbbb4d9bfefd471c8360540fd..8f7a73d46c84962e4271146d5da1cbca0ef12944 100644 --- a/ee/app/assets/javascripts/ai/settings/index.js +++ b/ee/app/assets/javascripts/ai/settings/index.js @@ -24,6 +24,7 @@ export const initAiSettings = (id, component) => { areDuoSettingsLocked, experimentFeaturesEnabled, duoCoreFeaturesEnabled, + hasOnlineLicense, promptCacheEnabled, redirectPath, updateId, @@ -66,6 +67,7 @@ export const initAiSettings = (id, component) => { duoAvailability, experimentFeaturesEnabled: parseBoolean(experimentFeaturesEnabled), duoCoreFeaturesEnabled: parseBoolean(duoCoreFeaturesEnabled), + hasOnlineLicense: parseBoolean(hasOnlineLicense), promptCacheEnabled: parseBoolean(promptCacheEnabled), disabledDirectConnectionMethod: parseBoolean(disabledDirectConnectionMethod), showEarlyAccessBanner: parseBoolean(showEarlyAccessBanner), diff --git a/ee/app/presenters/admin/ai_configuration_presenter.rb b/ee/app/presenters/admin/ai_configuration_presenter.rb index f8b490e36793af4cb5a2f2b7a8af93710999a8e8..a0221f5aa28e01fe5a9549054fa77c1f5b621af5 100644 --- a/ee/app/presenters/admin/ai_configuration_presenter.rb +++ b/ee/app/presenters/admin/ai_configuration_presenter.rb @@ -25,6 +25,7 @@ def settings are_prompt_cache_settings_allowed: true, beta_self_hosted_models_enabled: beta_self_hosted_models_enabled, can_manage_self_hosted_models: can_manage_self_hosted_models?, + has_online_license: has_online_license?, disabled_direct_connection_method: disabled_direct_code_suggestions?, duo_availability: duo_availability, duo_chat_expiration_column: duo_chat_expiration_column, @@ -50,6 +51,10 @@ def beta_self_hosted_models_enabled ::Ai::TestingTermsAcceptance.has_accepted? end + def has_online_license? + ::License.current&.online_cloud_license? + end + def can_manage_self_hosted_models? return false if ::Gitlab::CurrentSettings.gitlab_dedicated_instance? diff --git a/ee/spec/frontend/ai/settings/components/duo_core_features_form_spec.js b/ee/spec/frontend/ai/settings/components/duo_core_features_form_spec.js index 68970394ea97b9b4a509ee9f835ef15e5fb046b7..b9abcac3506c39f63cfbb4d6553dad48113b5f55 100644 --- a/ee/spec/frontend/ai/settings/components/duo_core_features_form_spec.js +++ b/ee/spec/frontend/ai/settings/components/duo_core_features_form_spec.js @@ -1,7 +1,10 @@ +import { nextTick } from 'vue'; import { GlLink, GlSprintf, GlFormGroup, GlFormCheckbox, GlIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import PromoPageLink from '~/vue_shared/components/promo_page_link/promo_page_link.vue'; import DuoCoreFeaturesForm from 'ee/ai/settings/components/duo_core_features_form.vue'; +import DuoCoreWithSelfHostedModal from 'ee/ai/settings/components/duo_core_with_self_hosted_modal.vue'; import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; const requirementsPath = `${DOCS_URL}/subscriptions/subscription-add-ons#gitlab-duo-core`; @@ -9,6 +12,7 @@ const mockTermsPath = `/handbook/legal/ai-functionality-terms/`; describe('DuoCoreFeaturesForm', () => { let wrapper; + const showModalSpy = jest.fn(); const createComponent = ({ props = {}, provide = {} } = {}) => { return shallowMountExtended(DuoCoreFeaturesForm, { @@ -19,6 +23,8 @@ describe('DuoCoreFeaturesForm', () => { }, provide: { isSaaS: true, + canManageSelfHostedModels: undefined, + hasOnlineLicense: undefined, ...provide, }, stubs: { @@ -26,6 +32,11 @@ describe('DuoCoreFeaturesForm', () => { GlSprintf, GlFormGroup, GlFormCheckbox, + DuoCoreWithSelfHostedModal: stubComponent(DuoCoreWithSelfHostedModal, { + methods: { + show: showModalSpy, + }, + }), }, }); }; @@ -33,6 +44,7 @@ describe('DuoCoreFeaturesForm', () => { const findFormCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findButton = () => wrapper.find('button'); const findIcon = () => wrapper.findComponent(GlIcon); + const findDuoCoreWithSelfHostedModal = () => wrapper.findComponent(DuoCoreWithSelfHostedModal); beforeEach(() => { wrapper = createComponent(); @@ -57,7 +69,7 @@ describe('DuoCoreFeaturesForm', () => { }); it('emits change event when checkbox is clicked', () => { - findFormCheckbox().vm.$emit('change'); + findFormCheckbox().vm.$emit('change', false); expect(wrapper.emitted('change')).toEqual([[false]]); }); @@ -80,6 +92,10 @@ describe('DuoCoreFeaturesForm', () => { it('renders the namespace description', () => { expect(wrapper.text()).toMatch('This setting applies to the whole top-level group.'); }); + + it('does not render Duo Self-Hosted warning modal', () => { + expect(findDuoCoreWithSelfHostedModal().exists()).toBe(false); + }); }); describe('on Self-Managed', () => { @@ -92,6 +108,85 @@ describe('DuoCoreFeaturesForm', () => { }); }); + describe('on Duo Self-Hosted with online license', () => { + beforeEach(() => { + wrapper = createComponent({ + provide: { isSaas: false, canManageSelfHostedModels: true, hasOnlineLicense: true }, + }); + }); + + it('renders Duo Self-Hosted warning modal', () => { + expect(findDuoCoreWithSelfHostedModal().exists()).toBe(true); + }); + + describe('when enabling Duo Core', () => { + beforeEach(() => { + findFormCheckbox().vm.$emit('change', true); + }); + + it('does not check checkbox initially', () => { + expect(findFormCheckbox().attributes('checked')).toBeUndefined(); + expect(wrapper.emitted('change')).toEqual([[false]]); + }); + + it('shows warning modal', () => { + expect(showModalSpy).toHaveBeenCalledTimes(1); + }); + + it('checks checkbox and emits event if modal primary action button is clicked', async () => { + // Simulate primary action button clicked + findDuoCoreWithSelfHostedModal().vm.$emit('confirm'); + await nextTick(); + + expect(findFormCheckbox().attributes('checked')).toBe('true'); + expect(wrapper.emitted('change')).toEqual([[false], [true]]); + }); + }); + + describe('when disabling Duo Core', () => { + beforeEach(async () => { + // first, enable Duo Core + findFormCheckbox().vm.$emit('change', true); + findDuoCoreWithSelfHostedModal().vm.$emit('confirm'); + await nextTick(); + + // disable Duo Core + findFormCheckbox().vm.$emit('change', false); + }); + + it('unchecks checkbox', () => { + expect(findFormCheckbox().attributes('checked')).toBeUndefined(); + }); + + it('does not display warning modal', () => { + expect(showModalSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('on Duo Self-Hosted with offline license', () => { + beforeEach(() => { + wrapper = createComponent({ + provide: { isSaas: false, canManageSelfHostedModels: true, hasOnlineLicense: false }, + }); + }); + + it('renders disabled checkbox', () => { + expect(findFormCheckbox().attributes('disabled')).toBeDefined(); + }); + + it('renders icon and tooltip text', () => { + expect(findIcon().exists()).toBe(true); + expect(findButton().attributes('title')).toEqual( + 'This setting requires access to the GitLab.com AI gateway.', + ); + }); + + it('does not render Duo Self-Hosted warning modal', () => { + expect(findDuoCoreWithSelfHostedModal().exists()).toBe(false); + }); + }); + it('does not render icon and tooltip initially', () => { wrapper = createComponent(); expect(findButton().exists()).toBe(false); diff --git a/ee/spec/frontend/ai/settings/components/duo_core_with_self_hosted_modal_spec.js b/ee/spec/frontend/ai/settings/components/duo_core_with_self_hosted_modal_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..94afe8375c9d534bc702d806fc723200a568977c --- /dev/null +++ b/ee/spec/frontend/ai/settings/components/duo_core_with_self_hosted_modal_spec.js @@ -0,0 +1,46 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DuoCoreWithSelfHostedModal from 'ee/ai/settings/components/duo_core_with_self_hosted_modal.vue'; + +describe('DuoCoreWithSelfHostedModal', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(DuoCoreWithSelfHostedModal); + }; + + const findModal = () => wrapper.findComponent(GlModal); + + beforeEach(() => { + createComponent(); + }); + + it('renders modal', () => { + expect(findModal().props('title')).toEqual('Duo Core with Duo Self-Hosted'); + expect(findModal().text()).toMatchInterpolatedText( + 'GitLab Duo Core provides GitLab Duo Chat and Code Suggestions to all billable users in GitLab and supported IDEs. By enabling GitLab Duo Core, you consent to using the GitLab AI vendor model and sending data to the GitLab AI gateway.', + ); + }); + + it('passes button props', () => { + expect(findModal().props('actionPrimary')).toStrictEqual({ + text: 'I understand', + attributes: { + variant: 'confirm', + category: 'primary', + }, + }); + expect(findModal().props('actionSecondary')).toStrictEqual({ + text: 'Cancel', + attributes: { + variant: 'default', + }, + }); + }); + + it('emits `confirm` event on primary button click', async () => { + await findModal().vm.$emit('primary'); + + expect(wrapper.emitted('confirm')).toHaveLength(1); + }); +}); diff --git a/ee/spec/presenters/admin/ai_configuration_presenter_spec.rb b/ee/spec/presenters/admin/ai_configuration_presenter_spec.rb index dbf9cb7fc2b53efdbf7808276e23e62b968a40af..daa8b41d5d1afbe62e639c74a08c98af79b56602 100644 --- a/ee/spec/presenters/admin/ai_configuration_presenter_spec.rb +++ b/ee/spec/presenters/admin/ai_configuration_presenter_spec.rb @@ -33,6 +33,7 @@ let(:beta_self_hosted_models_enabled) { true } let(:self_hosted_models) { true } let(:active_duo_add_ons_exist?) { true } + let(:license) { build(:license, cloud: true) } before do allow(GitlabSubscriptions::AddOnPurchase) @@ -47,6 +48,7 @@ .and_return(beta_self_hosted_models_enabled) allow(License).to receive(:feature_available?).with(:self_hosted_models).and_return self_hosted_models + allow(License).to receive(:current).and_return(license) allow(Gitlab::CurrentSettings).to receive(:current_application_settings).and_return application_settings @@ -114,6 +116,16 @@ it { expect(settings).to include(can_manage_self_hosted_models: 'false') } end + context 'with online cloud license' do + it { expect(settings).to include(has_online_license: 'true') } + end + + context 'without online cloud license' do + let(:license) { build(:license, cloud: false) } + + it { expect(settings).to include(has_online_license: 'false') } + end + context 'with enabled direct code suggestions' do let(:application_setting_attributes) { super().merge(disabled_direct_code_suggestions?: false) } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 53fedab90095ef580f2ef38891ddcf160aeb96ce..52e43cf0fcef4ab6cad6a91a71688bad3da7e6a6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2692,6 +2692,21 @@ msgstr "" msgid "AICatalog|unable to validate definition" msgstr "" +msgid "AIPowered|By enabling GitLab Duo Core, you consent to using the GitLab AI vendor model and sending data to the GitLab AI gateway." +msgstr "" + +msgid "AIPowered|Duo Core with Duo Self-Hosted" +msgstr "" + +msgid "AIPowered|GitLab Duo Core provides GitLab Duo Chat and Code Suggestions to all billable users in GitLab and supported IDEs." +msgstr "" + +msgid "AIPowered|GitLab Duo Core with self-hosted models is not supported. To use GitLab Duo Core with GitLa Duo Self-Hosted, you can use a %{linkStart}hybrid configuration%{linkEnd} that uses the GitLab AI vendor model." +msgstr "" + +msgid "AIPowered|I understand" +msgstr "" + msgid "AISummary|Generates a summary of this issue" msgstr "" @@ -6409,6 +6424,9 @@ msgstr "" msgid "AiPowered|This setting requires GitLab Duo availability to be on or off by default." msgstr "" +msgid "AiPowered|This setting requires access to the GitLab.com AI gateway." +msgstr "" + msgid "AiPowered|Turn off GitLab Duo Agent Platform" msgstr ""