diff --git a/app/client/src/PluginActionEditor/components/PluginActionForm/components/UQIEditor/UQIEditorForm.tsx b/app/client/src/PluginActionEditor/components/PluginActionForm/components/UQIEditor/UQIEditorForm.tsx index b4a6ea2adc5d..602562fc26b7 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionForm/components/UQIEditor/UQIEditorForm.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionForm/components/UQIEditor/UQIEditorForm.tsx @@ -4,14 +4,14 @@ import { usePluginActionContext } from "../../../../PluginActionContext"; import { QUERY_EDITOR_FORM_NAME } from "ee/constants/forms"; import { reduxForm } from "redux-form"; import { Flex } from "@appsmith/ads"; +import { PluginPackageName } from "entities/Plugin"; +import AppsmithAIDeprecationCallout from "components/editorComponents/AppsmithAIDeprecationCallout"; import { useGoogleSheetsSetDefaultProperty } from "./hooks/useGoogleSheetsSetDefaultProperty"; import { useFormData } from "./hooks/useFormData"; const UQIEditorForm = () => { - const { - editorConfig, - plugin: { uiComponent }, - } = usePluginActionContext(); + const { editorConfig, plugin } = usePluginActionContext(); + const { uiComponent } = plugin; // Set default values for Google Sheets useGoogleSheetsSetDefaultProperty(); @@ -29,6 +29,9 @@ const UQIEditorForm = () => { flexDirection="column" w="100%" > + {plugin.packageName === PluginPackageName.APPSMITH_AI && ( + + )} "GraphQL API"; export const CREATE_NEW_API_SECTION_HEADER = () => "APIs"; export const CREATE_NEW_SAAS_SECTION_HEADER = () => "SaaS Integrations"; export const CREATE_NEW_AI_SECTION_HEADER = () => "AI Integrations"; +// Appsmith AI deprecation (release 2.3): update this date if the shutdown date moves +export const APPSMITH_AI_KILL_DATE = "September 30, 2026"; +export const APPSMITH_AI_DEPRECATION_MESSAGE = () => + `Appsmith AI is deprecated and will stop working on ${APPSMITH_AI_KILL_DATE}. Migrate to OpenAI, Anthropic, or Google AI with your own API key. Uploaded files (file context) will not be available after this date.`; +export const APPSMITH_AI_DEPRECATION_LEARN_MORE = () => "Learn how to migrate"; export const CONNECT_A_DATASOURCE_HEADING = () => "Connect a datasource"; export const CONNECT_A_DATASOURCE_SUBHEADING = () => "Select a sample datasource or connect your own"; diff --git a/app/client/src/components/editorComponents/AppsmithAIDeprecationCallout.test.tsx b/app/client/src/components/editorComponents/AppsmithAIDeprecationCallout.test.tsx new file mode 100644 index 000000000000..a7e1988b4dc0 --- /dev/null +++ b/app/client/src/components/editorComponents/AppsmithAIDeprecationCallout.test.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { ThemeProvider } from "styled-components"; +import { BrowserRouter as Router } from "react-router-dom"; +import { lightTheme } from "selectors/themeSelectors"; +import AppsmithAIDeprecationCallout from "./AppsmithAIDeprecationCallout"; +import { DocsLink, openDoc } from "constants/DocumentationLinks"; +import { + APPSMITH_AI_DEPRECATION_LEARN_MORE, + APPSMITH_AI_KILL_DATE, + createMessage, +} from "ee/constants/messages"; + +jest.mock("constants/DocumentationLinks", () => ({ + ...jest.requireActual("constants/DocumentationLinks"), + openDoc: jest.fn(), +})); + +const renderCallout = () => + render( + + + + + , + ); + +describe("AppsmithAIDeprecationCallout", () => { + it("renders the deprecation message with the kill date", () => { + renderCallout(); + + expect( + screen.getByTestId("t--appsmith-ai-deprecation-callout"), + ).toBeInTheDocument(); + expect(screen.getByText(/Appsmith AI is deprecated/i)).toBeInTheDocument(); + expect( + screen.getByText(new RegExp(APPSMITH_AI_KILL_DATE)), + ).toBeInTheDocument(); + }); + + it("renders the migration docs link", () => { + renderCallout(); + + expect( + screen.getByText(createMessage(APPSMITH_AI_DEPRECATION_LEARN_MORE)), + ).toBeInTheDocument(); + }); + + it("opens the deprecation docs when the link is clicked", () => { + renderCallout(); + + fireEvent.click( + screen.getByText(createMessage(APPSMITH_AI_DEPRECATION_LEARN_MORE)), + ); + + expect(openDoc).toHaveBeenCalledWith(DocsLink.APPSMITH_AI_DEPRECATION); + }); +}); diff --git a/app/client/src/components/editorComponents/AppsmithAIDeprecationCallout.tsx b/app/client/src/components/editorComponents/AppsmithAIDeprecationCallout.tsx new file mode 100644 index 000000000000..6d261ce99ead --- /dev/null +++ b/app/client/src/components/editorComponents/AppsmithAIDeprecationCallout.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import styled from "styled-components"; +import { Callout } from "@appsmith/ads"; +import { DocsLink, openDoc } from "constants/DocumentationLinks"; +import { + APPSMITH_AI_DEPRECATION_LEARN_MORE, + APPSMITH_AI_DEPRECATION_MESSAGE, + createMessage, +} from "ee/constants/messages"; + +const CalloutWrapper = styled.div` + width: 100%; + margin-bottom: var(--ads-v2-spaces-4); +`; + +/** + * Deprecation notice for the managed Appsmith AI plugin (release 2.3). + * Shown on existing Appsmith AI datasources and queries; creating new + * Appsmith AI datasources is blocked separately (client filter + server guard). + */ +function AppsmithAIDeprecationCallout() { + return ( + + openDoc(DocsLink.APPSMITH_AI_DEPRECATION), + }, + ]} + > + {createMessage(APPSMITH_AI_DEPRECATION_MESSAGE)} + + + ); +} + +export default AppsmithAIDeprecationCallout; diff --git a/app/client/src/constants/DocumentationLinks.ts b/app/client/src/constants/DocumentationLinks.ts index 1083c4b2a800..66a88e80ee89 100644 --- a/app/client/src/constants/DocumentationLinks.ts +++ b/app/client/src/constants/DocumentationLinks.ts @@ -7,6 +7,7 @@ export enum DocsLink { QUERY = "QUERY", TROUBLESHOOT_ERROR = "TROUBLESHOOT_ERROR", QUERY_SETTINGS = "QUERY_SETTINGS", + APPSMITH_AI_DEPRECATION = "APPSMITH_AI_DEPRECATION", } const LinkData: Record = { @@ -22,6 +23,9 @@ const LinkData: Record = { "https://docs.appsmith.com/help-and-support/troubleshooting-guide", QUERY_SETTINGS: "https://docs.appsmith.com/connect-data/reference/query-settings", + // This URL hosts the Appsmith AI → BYOK migration guide (appsmith-docs#3025) + APPSMITH_AI_DEPRECATION: + "https://docs.appsmith.com/connect-data/reference/appsmith-ai", }; export const openDoc = (type: DocsLink, link?: string, subType?: string) => { diff --git a/app/client/src/pages/Editor/DataSourceEditor/index.tsx b/app/client/src/pages/Editor/DataSourceEditor/index.tsx index a34c19573a87..90877659194b 100644 --- a/app/client/src/pages/Editor/DataSourceEditor/index.tsx +++ b/app/client/src/pages/Editor/DataSourceEditor/index.tsx @@ -85,6 +85,7 @@ import { formValuesToDatasource } from "PluginActionEditor/transformers/RestAPID import { DSFormHeader } from "./DSFormHeader"; import type { PluginType } from "entities/Plugin"; import { DatasourceComponentTypes, PluginPackageName } from "entities/Plugin"; +import AppsmithAIDeprecationCallout from "components/editorComponents/AppsmithAIDeprecationCallout"; import DSDataFilter from "ee/components/DSDataFilter"; import { DEFAULT_ENV_ID } from "ee/api/ApiUtils"; import { isStorageEnvironmentCreated } from "ee/utils/Environments"; @@ -1031,6 +1032,11 @@ class DatasourceEditorRouter extends React.Component { showingTabsOnViewMode && "db-form-resizer-content-show-tabs" }`} > + {/* Mounted above DSEditorWrapper: the wrapper is a row flex, so a + child callout would render as a side column instead of a banner */} + {pluginPackageName === PluginPackageName.APPSMITH_AI && ( + + )} { // Sort the AI plugins alphabetically return a.name.localeCompare(b.name); }) - .filter((plugin) => plugin.type === PluginType.AI), + .filter( + (plugin) => + plugin.type === PluginType.AI && + // Appsmith AI is deprecated — creating new datasources for it is blocked + plugin.packageName !== PluginPackageName.APPSMITH_AI, + ), searchedPlugin, ) as Plugin[]; diff --git a/app/client/src/pages/Editor/IntegrationEditor/__tests__/aiPlugins.test.tsx b/app/client/src/pages/Editor/IntegrationEditor/__tests__/aiPlugins.test.tsx new file mode 100644 index 000000000000..17c213db9a6e --- /dev/null +++ b/app/client/src/pages/Editor/IntegrationEditor/__tests__/aiPlugins.test.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; +import store from "store"; +import { render } from "test/testUtils"; +import { PluginPackageName } from "entities/Plugin"; +import AIPlugins from "../AIPlugins"; + +const aiPlugins = [ + { + id: "appsmith-ai-plugin-id", + name: "Appsmith AI", + type: "AI", + packageName: PluginPackageName.APPSMITH_AI, + iconLocation: "", + }, + { + id: "openai-plugin-id", + name: "OpenAI", + type: "AI", + packageName: "openai-plugin", + iconLocation: "", + }, +]; + +describe("AIPlugins create-new list", () => { + it("hides deprecated Appsmith AI but keeps other AI plugins", () => { + store.dispatch({ + type: ReduxActionTypes.FETCH_PLUGINS_SUCCESS, + payload: aiPlugins, + }); + + const { container } = render( + {}} />, + ); + + // BYOK AI plugins must remain creatable + expect( + container.querySelector(".t--createBlankApi-openai-plugin"), + ).not.toBeNull(); + // The deprecated managed Appsmith AI plugin must not be offered for creation + expect( + container.querySelector( + `.t--createBlankApi-${PluginPackageName.APPSMITH_AI}`, + ), + ).toBeNull(); + }); +}); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/base/DatasourceServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/base/DatasourceServiceCE.java index 7f88310d3185..1b810c015525 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/base/DatasourceServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/base/DatasourceServiceCE.java @@ -65,6 +65,12 @@ public interface DatasourceServiceCE { Mono create(Datasource datasource); + /** + * Same as {@link #create(Datasource)} but skips the deprecated-plugin creation guard. Meant for internal flows + * (e.g. forking an application) that must keep working for datasources of deprecated plugins that already exist. + */ + Mono createWithoutDeprecationCheck(Datasource datasource); + Mono createWithoutPermissions(Datasource datasource); Mono createWithoutPermissions( diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/base/DatasourceServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/base/DatasourceServiceCEImpl.java index 5fcedc47b575..eea8ed1ec01a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/base/DatasourceServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/base/DatasourceServiceCEImpl.java @@ -1,6 +1,7 @@ package com.appsmith.server.datasources.base; import com.appsmith.external.constants.AnalyticsEvents; +import com.appsmith.external.constants.PluginConstants; import com.appsmith.external.enums.FeatureFlagEnum; import com.appsmith.external.models.Datasource; import com.appsmith.external.models.DatasourceConfiguration; @@ -139,11 +140,40 @@ public DatasourceServiceCEImpl( @Override public Mono create(Datasource datasource) { + return validatePluginNotDeprecated(datasource) + .then(Mono.defer(() -> createWithoutDeprecationCheck(datasource))); + } + + @Override + public Mono createWithoutDeprecationCheck(Datasource datasource) { return workspacePermission .getDatasourceCreatePermission() .flatMap(permission -> createEx(datasource, permission, false, null)); } + /** + * Plugins for which creating new datasources is blocked because the plugin is deprecated. + * EE overrides this to extend the blocked list (e.g. appsmith-agent-plugin). + */ + protected Set getDeprecatedPluginPackageNames() { + return Set.of(PluginConstants.PackageName.APPSMITH_AI_PLUGIN); + } + + private Mono validatePluginNotDeprecated(Datasource datasource) { + // Only block brand-new datasources. Calls that carry an id are storage-saves for datasources that already + // exist and must keep working so users can keep using them until they migrate. A missing pluginId falls + // through to the INVALID_PARAMETER validation in createEx. + if (hasText(datasource.getId()) || !hasText(datasource.getPluginId())) { + return Mono.empty(); + } + + return pluginService + .findById(datasource.getPluginId()) + .filter(plugin -> getDeprecatedPluginPackageNames().contains(plugin.getPackageName())) + .flatMap(plugin -> Mono.error( + new AppsmithException(AppsmithError.DEPRECATED_DATASOURCE_PLUGIN, plugin.getName()))); + } + // TODO: Check usage @Override public Mono createWithoutPermissions( diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/fork/DatasourceForkableServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/fork/DatasourceForkableServiceCEImpl.java index e9655431471d..64d163c00e74 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/fork/DatasourceForkableServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/datasources/fork/DatasourceForkableServiceCEImpl.java @@ -157,13 +157,17 @@ private Mono createSuffixedDatasource(Datasource datasource) { private Mono createSuffixedDatasource(Datasource datasource, String name, int suffix) { final String actualName = name + (suffix == 0 ? "" : " (" + suffix + ")"); datasource.setName(actualName); - return datasourceService.create(datasource).onErrorResume(DuplicateKeyException.class, error -> { - if (error.getMessage() != null - && error.getMessage().contains("workspace_datasource_deleted_compound_index")) { - // The duplicate key error is because of the `name` field. - return createSuffixedDatasource(datasource, name, 1 + suffix); - } - throw error; - }); + // Forking must keep working for apps that already contain datasources of deprecated plugins, + // so skip the deprecated-plugin creation guard here. + return datasourceService + .createWithoutDeprecationCheck(datasource) + .onErrorResume(DuplicateKeyException.class, error -> { + if (error.getMessage() != null + && error.getMessage().contains("workspace_datasource_deleted_compound_index")) { + // The duplicate key error is because of the `name` field. + return createSuffixedDatasource(datasource, name, 1 + suffix); + } + throw error; + }); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java index 4bf2a1efa211..9dbf4c614c09 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java @@ -516,6 +516,15 @@ public enum AppsmithError { "Datasource cannot be deleted", ErrorType.BAD_REQUEST, null), + DEPRECATED_DATASOURCE_PLUGIN( + 400, + AppsmithErrorCode.DEPRECATED_DATASOURCE_PLUGIN.getCode(), + "Creating new datasources with the {0} plugin is not supported because the plugin is deprecated." + + " Please create an OpenAI, Anthropic, or Google AI datasource with your own API key instead.", + AppsmithErrorAction.DEFAULT, + "Plugin deprecated", + ErrorType.BAD_REQUEST, + null), WORKSPACE_ID_NOT_GIVEN( 400, AppsmithErrorCode.WORKSPACE_ID_NOT_GIVEN.getCode(), diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java index 91f8a319badd..cc6fd35acc35 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java @@ -82,6 +82,7 @@ public enum AppsmithErrorCode { INVALID_DATASOURCE("AE-DTS-4013", "Invalid datasource"), INVALID_DATASOURCE_CONFIGURATION("AE-DTS-4015", "Invalid datasource configuration"), DATASOURCE_HAS_ACTIONS("AE-DTS-4030", "Datasource has actions"), + DEPRECATED_DATASOURCE_PLUGIN("AE-DTS-4031", "Datasource creation is blocked for a deprecated plugin"), APPLICATION_FORKING_NOT_ALLOWED("AE-FRK-4034", "Application forking not allowed"), INVALID_GIT_CONFIGURATION("AE-GIT-4031", "Invalid git configuration"), INVALID_GIT_SSH_CONFIGURATION("AE-GIT-4032", "Invalid git ssh configuration"), diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/DatasourceServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/DatasourceServiceTest.java index 06df6669fe29..6f1c8e02b8a0 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/DatasourceServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/DatasourceServiceTest.java @@ -1,5 +1,6 @@ package com.appsmith.server.services; +import com.appsmith.external.constants.PluginConstants; import com.appsmith.external.helpers.EncryptionHelper; import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionDTO; @@ -12,6 +13,7 @@ import com.appsmith.external.models.DatasourceTestResult; import com.appsmith.external.models.Endpoint; import com.appsmith.external.models.OAuth2; +import com.appsmith.external.models.PluginType; import com.appsmith.external.models.Policy; import com.appsmith.external.models.SSLDetails; import com.appsmith.external.models.UploadedFile; @@ -34,6 +36,7 @@ import com.appsmith.server.plugins.base.PluginService; import com.appsmith.server.repositories.NewActionRepository; import com.appsmith.server.repositories.PermissionGroupRepository; +import com.appsmith.server.repositories.PluginRepository; import com.appsmith.server.repositories.WorkspaceRepository; import com.appsmith.server.solutions.ApplicationPermission; import com.appsmith.server.solutions.EnvironmentPermission; @@ -109,6 +112,9 @@ public class DatasourceServiceTest { @Autowired PermissionGroupRepository permissionGroupRepository; + @Autowired + PluginRepository pluginRepository; + @MockBean PluginExecutorHelper pluginExecutorHelper; @@ -294,6 +300,95 @@ public void createDatasourceWithId() { .verify(); } + private Plugin getOrCreateAppsmithAiPlugin() { + Plugin aiPlugin = pluginService + .findByPackageName(PluginConstants.PackageName.APPSMITH_AI_PLUGIN) + .block(); + + if (aiPlugin == null) { + aiPlugin = new Plugin(); + aiPlugin.setName(PluginConstants.PluginName.APPSMITH_AI_PLUGIN_NAME); + aiPlugin.setType(PluginType.AI); + aiPlugin.setPackageName(PluginConstants.PackageName.APPSMITH_AI_PLUGIN); + aiPlugin = pluginRepository.save(aiPlugin).block(); + } + + return aiPlugin; + } + + private Datasource buildAppsmithAiDatasource(Plugin aiPlugin, String name) { + Datasource datasource = new Datasource(); + datasource.setName(name); + datasource.setWorkspaceId(workspaceId); + datasource.setPluginId(aiPlugin.getId()); + HashMap storages = new HashMap<>(); + storages.put(defaultEnvironmentId, new DatasourceStorageDTO(null, defaultEnvironmentId, null)); + datasource.setDatasourceStorages(storages); + return datasource; + } + + @Test + @WithUserDetails(value = "api_user") + public void createDatasource_withDeprecatedAppsmithAiPlugin_throwsException() { + Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())) + .thenReturn(Mono.just(new MockPluginExecutor())); + + Plugin aiPlugin = getOrCreateAppsmithAiPlugin(); + Datasource datasource = buildAppsmithAiDatasource(aiPlugin, "Deprecated Appsmith AI DS"); + + StepVerifier.create(datasourceService.create(datasource)) + .expectErrorMatches(throwable -> throwable instanceof AppsmithException + && throwable + .getMessage() + .equals(AppsmithError.DEPRECATED_DATASOURCE_PLUGIN.getMessage(aiPlugin.getName()))) + .verify(); + } + + @Test + @WithUserDetails(value = "api_user") + public void createWithoutDeprecationCheck_withDeprecatedAppsmithAiPlugin_succeeds() { + Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())) + .thenReturn(Mono.just(new MockPluginExecutor())); + + Plugin aiPlugin = getOrCreateAppsmithAiPlugin(); + Datasource datasource = buildAppsmithAiDatasource(aiPlugin, "Existing Appsmith AI DS"); + + // This is the path fork uses; it must keep working for already-existing datasources. + StepVerifier.create(datasourceService.createWithoutDeprecationCheck(datasource)) + .assertNext(createdDatasource -> { + assertThat(createdDatasource.getId()).isNotEmpty(); + assertThat(createdDatasource.getPluginId()).isEqualTo(aiPlugin.getId()); + }) + .verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void createDatasource_existingAppsmithAiDatasource_storageSaveAllowed() { + Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())) + .thenReturn(Mono.just(new MockPluginExecutor())); + + Plugin aiPlugin = getOrCreateAppsmithAiPlugin(); + Datasource datasource = buildAppsmithAiDatasource(aiPlugin, "Existing Appsmith AI DS for storage save"); + + Datasource savedDatasource = + datasourceService.createWithoutDeprecationCheck(datasource).block(); + assertThat(savedDatasource).isNotNull(); + assertThat(savedDatasource.getId()).isNotEmpty(); + + // Calls that carry an id are storage-saves for existing datasources and must bypass the deprecation guard. + Datasource existingDatasource = new Datasource(); + existingDatasource.setId(savedDatasource.getId()); + existingDatasource.setWorkspaceId(workspaceId); + existingDatasource.setPluginId(aiPlugin.getId()); + existingDatasource.setDatasourceStorages(new HashMap<>(savedDatasource.getDatasourceStorages())); + + StepVerifier.create(datasourceService.create(existingDatasource)) + .assertNext( + resultDatasource -> assertThat(resultDatasource.getId()).isEqualTo(savedDatasource.getId())) + .verifyComplete(); + } + @Test @WithUserDetails(value = "api_user") public void createDatasourceNotInstalledPlugin() {