diff --git a/package-lock.json b/package-lock.json index cfe9f31f248..bbcde5668d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33772,6 +33772,7 @@ "version": "1.7.2", "resolved": "https://registry.npmjs.org/mongodb-build-info/-/mongodb-build-info-1.7.2.tgz", "integrity": "sha512-eoLFZvCIjcwijYJdxvYupj1c+55VAVm0o4gBJjrcDxxmmpm+bC4Ix9ayZbyhQdVXDZAGDi03NA0GghXjBVXnxg==", + "license": "Apache-2.0", "dependencies": { "mongodb-connection-string-url": "^3.0.0" } @@ -51318,6 +51319,7 @@ "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.19.0", + "mongodb-build-info": "^1.7.2", "mongodb-data-service": "^22.30.2", "mongodb-log-writer": "^2.3.4", "mongodb-ns": "^2.4.2", @@ -63803,6 +63805,7 @@ "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.19.0", + "mongodb-build-info": "^1.7.2", "mongodb-data-service": "^22.30.2", "mongodb-log-writer": "^2.3.4", "mongodb-ns": "^2.4.2", diff --git a/packages/compass-assistant/package.json b/packages/compass-assistant/package.json index 39b5b420f2d..8cb59c19a67 100644 --- a/packages/compass-assistant/package.json +++ b/packages/compass-assistant/package.json @@ -32,6 +32,8 @@ "types": "./dist/index.d.ts", "scripts": { "bootstrap": "npm run compile", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" || true", + "precompile": "npm run clean", "compile": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig-lint.json --noEmit", "eslint": "eslint-compass", @@ -53,13 +55,13 @@ "@mongodb-js/atlas-service": "^0.56.0", "@mongodb-js/compass-app-registry": "^9.4.20", "@mongodb-js/compass-components": "^1.49.0", + "@mongodb-js/compass-generative-ai": "^0.51.0", + "@mongodb-js/compass-logging": "^1.7.12", "@mongodb-js/compass-telemetry": "^1.14.0", "@mongodb-js/connection-info": "^0.17.1", - "@mongodb-js/compass-logging": "^1.7.12", - "@mongodb-js/compass-generative-ai": "^0.51.0", - "mongodb-connection-string-url": "^3.0.1", "ai": "^5.0.26", "compass-preferences-model": "^2.51.0", + "mongodb-connection-string-url": "^3.0.1", "react": "^17.0.2", "throttleit": "^2.1.0", "use-sync-external-store": "^1.5.0" diff --git a/packages/compass-assistant/src/assistant-chat.spec.tsx b/packages/compass-assistant/src/assistant-chat.spec.tsx index 768f338c799..c4111c8882a 100644 --- a/packages/compass-assistant/src/assistant-chat.spec.tsx +++ b/packages/compass-assistant/src/assistant-chat.spec.tsx @@ -8,6 +8,7 @@ import { import { AssistantChat } from './assistant-chat'; import { expect } from 'chai'; import { createMockChat } from '../test/utils'; +import type { ConnectionInfo } from '@mongodb-js/connection-info'; import { AssistantActionsContext, type AssistantMessage, @@ -36,13 +37,14 @@ describe('AssistantChat', function () { function renderWithChat( messages: AssistantMessage[], { + connections, status, }: { + connections?: ConnectionInfo[]; status?: 'submitted' | 'streaming'; } = {} ) { const chat = createMockChat({ messages, status }); - // The chat component does not use chat.sendMessage() directly, it uses // ensureOptInAndSend() via the AssistantActionsContext. const ensureOptInAndSendStub = sinon @@ -59,8 +61,11 @@ describe('AssistantChat', function () { }; const result = render( - - + + , + { + connections, + } ); return { result, @@ -180,8 +185,58 @@ describe('AssistantChat', function () { ); }); - it('calls ensureOptInAndSend when form is submitted', async function () { - const { ensureOptInAndSendStub, result } = renderWithChat([]); + describe('non-genuine MongoDB host handling', function () { + it('shows warning message in chat when connected to non-genuine MongoDB', function () { + const chat = createMockChat({ messages: [] }); + render(); + + expect(chat.messages).to.have.length(1); + expect(chat.messages[0].id).to.equal('non-genuine-warning'); + + const warningMessage = screen.getByText( + /MongoDB Assistant will not provide accurate guidance for non-genuine hosts/ + ); + expect(warningMessage).to.exist; + }); + + it('does not show warning message when all connections are genuine', function () { + const chat = createMockChat({ messages: [] }); + render(, { + connections: [], + }); + + const warningMessage = screen.queryByText( + /MongoDB Assistant will not provide accurate guidance for non-genuine hosts/ + ); + expect(warningMessage).to.not.exist; + }); + + it('warning message is removed when all active connections are changed to genuine', async function () { + const chat = createMockChat({ messages: [] }); + const { rerender } = render( + , + {} + ); + + expect( + screen.getByText( + /MongoDB Assistant will not provide accurate guidance for non-genuine hosts/ + ) + ).to.exist; + + rerender(); + + await waitFor(() => { + const warningMessage = screen.queryByText( + /MongoDB Assistant will not provide accurate guidance for non-genuine hosts/ + ); + expect(warningMessage).to.not.exist; + }); + }); + }); + + it('calls sendMessage when form is submitted', async function () { + const { result, ensureOptInAndSendStub } = renderWithChat([]); const { track } = result; const inputField = screen.getByPlaceholderText( 'Ask MongoDB Assistant a question' diff --git a/packages/compass-assistant/src/assistant-chat.tsx b/packages/compass-assistant/src/assistant-chat.tsx index ab33c111d21..3f41eb287b0 100644 --- a/packages/compass-assistant/src/assistant-chat.tsx +++ b/packages/compass-assistant/src/assistant-chat.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext } from 'react'; +import React, { useCallback, useEffect, useContext } from 'react'; import type { AssistantMessage } from './compass-assistant-provider'; import { AssistantActionsContext } from './compass-assistant-provider'; import type { Chat } from './@ai-sdk/react/chat-react'; @@ -20,6 +20,7 @@ import { Link, } from '@mongodb-js/compass-components'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; +import { NON_GENUINE_WARNING_MESSAGE } from './preset-messages'; const { DisclaimerText } = LgChatChatDisclaimer; const { ChatWindow } = LgChatChatWindow; @@ -32,6 +33,7 @@ const GEN_AI_FAQ_LINK = 'https://www.mongodb.com/docs/generative-ai-faq/'; interface AssistantChatProps { chat: Chat; + hasNonGenuineConnections: boolean; } const assistantChatStyles = css({ @@ -127,11 +129,13 @@ const errorBannerWrapperStyles = css({ export const AssistantChat: React.FunctionComponent = ({ chat, + hasNonGenuineConnections, }) => { const track = useTelemetry(); const darkMode = useDarkMode(); + const { ensureOptInAndSend } = useContext(AssistantActionsContext); - const { messages, status, error, clearError } = useChat({ + const { messages, status, error, clearError, setMessages } = useChat({ chat, onError: (error) => { track('Assistant Response Failed', () => ({ @@ -140,6 +144,23 @@ export const AssistantChat: React.FunctionComponent = ({ }, }); + useEffect(() => { + const hasExistingNonGenuineWarning = chat.messages.some( + (message) => message.id === 'non-genuine-warning' + ); + if (hasNonGenuineConnections && !hasExistingNonGenuineWarning) { + setMessages((messages) => { + return [NON_GENUINE_WARNING_MESSAGE, ...messages]; + }); + } else if (hasExistingNonGenuineWarning && !hasNonGenuineConnections) { + setMessages((messages) => { + return messages.filter( + (message) => message.id !== 'non-genuine-warning' + ); + }); + } + }, [hasNonGenuineConnections, chat, setMessages]); + // Transform AI SDK messages to LeafyGreen chat format and reverse the order of the messages // for displaying it correctly with flex-direction: column-reverse. const lgMessages = messages diff --git a/packages/compass-assistant/src/compass-assistant-drawer.tsx b/packages/compass-assistant/src/compass-assistant-drawer.tsx index 5201f1ba5d3..618de0d6569 100644 --- a/packages/compass-assistant/src/compass-assistant-drawer.tsx +++ b/packages/compass-assistant/src/compass-assistant-drawer.tsx @@ -33,7 +33,8 @@ const assistantTitleTextStyles = css({ */ export const CompassAssistantDrawer: React.FunctionComponent<{ autoOpen?: boolean; -}> = ({ autoOpen }) => { + hasNonGenuineConnections?: boolean; +}> = ({ autoOpen, hasNonGenuineConnections = false }) => { const chat = useContext(AssistantContext); const { clearChat } = useContext(AssistantActionsContext); @@ -88,7 +89,10 @@ export const CompassAssistantDrawer: React.FunctionComponent<{ glyph="Sparkle" autoOpen={autoOpen} > - + ); }; diff --git a/packages/compass-assistant/src/compass-assistant-provider.spec.tsx b/packages/compass-assistant/src/compass-assistant-provider.spec.tsx index 16c9abb9145..9514e191998 100644 --- a/packages/compass-assistant/src/compass-assistant-provider.spec.tsx +++ b/packages/compass-assistant/src/compass-assistant-provider.spec.tsx @@ -25,7 +25,7 @@ import type { AtlasAuthService } from '@mongodb-js/atlas-service/provider'; import type { AtlasService } from '@mongodb-js/atlas-service/provider'; import { CompassAssistantDrawer } from './compass-assistant-drawer'; import { createMockChat } from '../test/utils'; -import { type AtlasAiService } from '@mongodb-js/compass-generative-ai/provider'; +import type { AtlasAiService } from '@mongodb-js/compass-generative-ai/provider'; function createMockProvider({ mockAtlasService, @@ -68,12 +68,14 @@ const TestComponent: React.FunctionComponent<{ mockAtlasService?: any; mockAtlasAiService?: any; mockAtlasAuthService?: any; + hasNonGenuineConnections?: boolean; }> = ({ chat, autoOpen, mockAtlasService, mockAtlasAiService, mockAtlasAuthService, + hasNonGenuineConnections, }) => { const MockedProvider = createMockProvider({ mockAtlasService: mockAtlasService as unknown as AtlasService, @@ -86,7 +88,10 @@ const TestComponent: React.FunctionComponent<{
Provider children
- +
@@ -248,15 +253,21 @@ describe('CompassAssistantProvider', function () { } }); - async function renderOpenAssistantDrawer( - mockChat: Chat, - mockAtlasAiService?: any - ): Promise> { + async function renderOpenAssistantDrawer({ + chat, + atlastAiService, + hasNonGenuineConnections, + }: { + chat: Chat; + atlastAiService?: Partial; + hasNonGenuineConnections?: boolean; + }): Promise> { const result = render( , { preferences: { @@ -278,7 +289,7 @@ describe('CompassAssistantProvider', function () { it('displays messages in the chat feed', async function () { const mockChat = createMockChat({ messages: mockMessages }); - await renderOpenAssistantDrawer(mockChat); + await renderOpenAssistantDrawer({ chat: mockChat }); // ensureAiFeatureAccess is async await waitFor(() => { @@ -309,7 +320,7 @@ describe('CompassAssistantProvider', function () { const sendMessageSpy = sinon.spy(mockChat, 'sendMessage'); - await renderOpenAssistantDrawer(mockChat); + await renderOpenAssistantDrawer({ chat: mockChat }); const input = screen.getByPlaceholderText( 'Ask MongoDB Assistant a question' @@ -348,7 +359,7 @@ describe('CompassAssistantProvider', function () { const sendMessageSpy = sinon.spy(mockChat, 'sendMessage'); - await renderOpenAssistantDrawer(mockChat); + await renderOpenAssistantDrawer({ chat: mockChat }); userEvent.type( screen.getByPlaceholderText('Ask MongoDB Assistant a question'), @@ -367,7 +378,7 @@ describe('CompassAssistantProvider', function () { }); it('will not send new messages if the user does not opt in', async function () { - const mockChat = new Chat({ + const chat = new Chat({ messages: [ { id: 'assistant', @@ -385,13 +396,13 @@ describe('CompassAssistantProvider', function () { }, }); - const mockAtlasAiService = { + const atlastAiService = { ensureAiFeatureAccess: sinon.stub().rejects(), }; - const sendMessageSpy = sinon.spy(mockChat, 'sendMessage'); + const sendMessageSpy = sinon.spy(chat, 'sendMessage'); - await renderOpenAssistantDrawer(mockChat, mockAtlasAiService); + await renderOpenAssistantDrawer({ chat, atlastAiService }); userEvent.type( screen.getByPlaceholderText('Ask MongoDB Assistant a question'), @@ -400,7 +411,7 @@ describe('CompassAssistantProvider', function () { userEvent.click(screen.getByLabelText('Send message')); await waitFor(() => { - expect(mockAtlasAiService.ensureAiFeatureAccess.calledOnce).to.be.true; + expect(atlastAiService.ensureAiFeatureAccess.calledOnce).to.be.true; expect(sendMessageSpy.called).to.be.false; }); expect(screen.queryByText('Hello assistant!')).to.not.exist; @@ -410,7 +421,7 @@ describe('CompassAssistantProvider', function () { it('clears the chat when the user clicks and confirms', async function () { const mockChat = createMockChat({ messages: mockMessages }); - await renderOpenAssistantDrawer(mockChat); + await renderOpenAssistantDrawer({ chat: mockChat }); const clearButton = screen.getByTestId('assistant-clear-chat'); userEvent.click(clearButton); @@ -440,7 +451,7 @@ describe('CompassAssistantProvider', function () { it('does not clear the chat when the user clicks the button and cancels', async function () { const mockChat = createMockChat({ messages: mockMessages }); - await renderOpenAssistantDrawer(mockChat); + await renderOpenAssistantDrawer({ chat: mockChat }); const clearButton = screen.getByTestId('assistant-clear-chat'); userEvent.click(clearButton); @@ -466,6 +477,43 @@ describe('CompassAssistantProvider', function () { expect(screen.getByTestId('assistant-message-1')).to.exist; expect(screen.getByTestId('assistant-message-2')).to.exist; }); + + it('should persist permanent warning messages when clearing chat', async function () { + const mockChat = createMockChat({ messages: mockMessages }); + await renderOpenAssistantDrawer({ + chat: mockChat, + hasNonGenuineConnections: true, + }); + + const clearButton = screen.getByTestId('assistant-clear-chat'); + userEvent.click(clearButton); + + await waitFor(() => { + expect(screen.getByTestId('assistant-confirm-clear-chat-modal')).to + .exist; + }); + + // There should be messages in the chat + expect(screen.getByTestId('assistant-message-1')).to.exist; + expect(screen.getByTestId('assistant-message-2')).to.exist; + expect(screen.getByTestId('assistant-message-non-genuine-warning')).to + .exist; + + const modal = screen.getByTestId('assistant-confirm-clear-chat-modal'); + const confirmButton = within(modal).getByText('Clear chat'); + userEvent.click(confirmButton); + + await waitForElementToBeRemoved(() => + screen.getByTestId('assistant-confirm-clear-chat-modal') + ); + + // The non-genuine warning message should still be in the chat + expect(screen.getByTestId('assistant-message-non-genuine-warning')).to + .exist; + // The user messages should be gone + expect(screen.queryByTestId('assistant-message-1')).to.not.exist; + expect(screen.queryByTestId('assistant-message-2')).to.not.exist; + }); }); }); diff --git a/packages/compass-assistant/src/compass-assistant-provider.tsx b/packages/compass-assistant/src/compass-assistant-provider.tsx index bd9a7058346..c99073534a2 100644 --- a/packages/compass-assistant/src/compass-assistant-provider.tsx +++ b/packages/compass-assistant/src/compass-assistant-provider.tsx @@ -35,6 +35,10 @@ export type AssistantMessage = UIMessage & { metadata?: { /** The text to display instead of the message text. */ displayText?: string; + /** Whether to persist the message after chat clearing. + * Used for warning messages in cases like using non-genuine MongoDB. + */ + isPermanent?: boolean; }; }; @@ -188,7 +192,9 @@ export const AssistantProvider: React.FunctionComponent< buildProactiveInsightsPrompt ), clearChat: () => { - chat.messages = []; + chat.messages = chat.messages.filter( + (message) => message.metadata?.isPermanent + ); }, ensureOptInAndSend: async ( message: SendMessage, diff --git a/packages/compass-assistant/src/preset-messages.ts b/packages/compass-assistant/src/preset-messages.ts new file mode 100644 index 00000000000..06b8bdf0642 --- /dev/null +++ b/packages/compass-assistant/src/preset-messages.ts @@ -0,0 +1,17 @@ +import type { AssistantMessage } from './compass-assistant-provider'; + +export const NON_GENUINE_WARNING_MESSAGE: AssistantMessage = { + id: 'non-genuine-warning', + parts: [ + { + type: 'text', + text: 'The user is connected to a non-genuine MongoDB server. This causes many features to work differently or not work at all, make sure to always warn the user about this.', + }, + ], + metadata: { + displayText: + 'You are connected to **a non-genuine MongoDB server**. MongoDB Assistant will not provide accurate guidance for non-genuine hosts, and we encourage users to use real MongoDB deployments to take full advantage of our developer tools.', + isPermanent: true, + }, + role: 'assistant', +}; diff --git a/packages/compass-web/package.json b/packages/compass-web/package.json index dc4f6211d2c..2aab43139fc 100644 --- a/packages/compass-web/package.json +++ b/packages/compass-web/package.json @@ -128,6 +128,7 @@ "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.19.0", + "mongodb-build-info": "^1.7.2", "mongodb-data-service": "^22.30.2", "mongodb-log-writer": "^2.3.4", "mongodb-ns": "^2.4.2", diff --git a/packages/compass-web/src/compass-assistant-drawer.tsx b/packages/compass-web/src/compass-assistant-drawer.tsx new file mode 100644 index 00000000000..b09ac7683c0 --- /dev/null +++ b/packages/compass-web/src/compass-assistant-drawer.tsx @@ -0,0 +1,21 @@ +import { useConnectionIds } from '@mongodb-js/compass-connections/provider'; +import { getGenuineMongoDB } from 'mongodb-build-info'; +import React from 'react'; +import { CompassAssistantDrawer } from '@mongodb-js/compass-assistant'; + +// TODO(COMPASS-7830): This is a temporary solution to pass the +// hasNonGenuineConnections prop to the CompassAssistantDrawer as otherwise +// we end up with a circular dependency. +export function CompassAssistantDrawerWithConnections() { + // Check for non-genuine connections + const activeConnectionIds = useConnectionIds( + (conn) => + getGenuineMongoDB(conn.info.connectionOptions.connectionString) + .isGenuine === false && conn.status === 'connected' + ); + return ( + 0} + /> + ); +} diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 8198dd81337..4845a208fb3 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -62,10 +62,8 @@ import { WebWorkspaceTab as WelcomeWorkspaceTab } from '@mongodb-js/compass-welc import { useCompassWebPreferences } from './preferences'; import { DataModelingWorkspaceTab as DataModelingWorkspace } from '@mongodb-js/compass-data-modeling'; import { DataModelStorageServiceProviderInMemory } from '@mongodb-js/compass-data-modeling/web'; -import { - CompassAssistantDrawer, - CompassAssistantProvider, -} from '@mongodb-js/compass-assistant'; +import { CompassAssistantProvider } from '@mongodb-js/compass-assistant'; +import { CompassAssistantDrawerWithConnections } from './compass-assistant-drawer'; /** @public */ export type TrackFunction = ( @@ -229,7 +227,7 @@ function CompassWorkspace({ - + ); }} diff --git a/packages/compass/src/app/components/compass-assistant-drawer.tsx b/packages/compass/src/app/components/compass-assistant-drawer.tsx new file mode 100644 index 00000000000..b09ac7683c0 --- /dev/null +++ b/packages/compass/src/app/components/compass-assistant-drawer.tsx @@ -0,0 +1,21 @@ +import { useConnectionIds } from '@mongodb-js/compass-connections/provider'; +import { getGenuineMongoDB } from 'mongodb-build-info'; +import React from 'react'; +import { CompassAssistantDrawer } from '@mongodb-js/compass-assistant'; + +// TODO(COMPASS-7830): This is a temporary solution to pass the +// hasNonGenuineConnections prop to the CompassAssistantDrawer as otherwise +// we end up with a circular dependency. +export function CompassAssistantDrawerWithConnections() { + // Check for non-genuine connections + const activeConnectionIds = useConnectionIds( + (conn) => + getGenuineMongoDB(conn.info.connectionOptions.connectionString) + .isGenuine === false && conn.status === 'connected' + ); + return ( + 0} + /> + ); +} diff --git a/packages/compass/src/app/components/workspace.tsx b/packages/compass/src/app/components/workspace.tsx index fc152693c52..e53a87fe1ae 100644 --- a/packages/compass/src/app/components/workspace.tsx +++ b/packages/compass/src/app/components/workspace.tsx @@ -40,7 +40,7 @@ import updateTitle from '../utils/update-title'; import { getConnectionTitle } from '@mongodb-js/connection-info'; import { useConnectionsListRef } from '@mongodb-js/compass-connections/provider'; import { DataModelingWorkspaceTab } from '@mongodb-js/compass-data-modeling'; -import { CompassAssistantDrawer } from '@mongodb-js/compass-assistant'; +import { CompassAssistantDrawerWithConnections } from './compass-assistant-drawer'; export default function Workspace({ appName, @@ -112,7 +112,7 @@ export default function Workspace({ - + )} >