diff --git a/cypress/e2e/chat/model-selection-persistence.cy.ts b/cypress/e2e/chat/model-selection-persistence.cy.ts new file mode 100644 index 00000000..afaf9e6b --- /dev/null +++ b/cypress/e2e/chat/model-selection-persistence.cy.ts @@ -0,0 +1,205 @@ +import { v4 as uuidv4 } from 'uuid'; +import { AuthTestUtils } from '../../support/auth-utils'; +import { TestTool } from '../../support/page-utils'; +import { PageSelectors, SidebarSelectors, ModelSelectorSelectors } from '../../support/selectors'; + +describe('Chat Model Selection Persistence Tests', () => { + const APPFLOWY_BASE_URL = Cypress.env('APPFLOWY_BASE_URL'); + const APPFLOWY_GOTRUE_BASE_URL = Cypress.env('APPFLOWY_GOTRUE_BASE_URL'); + const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; + let testEmail: string; + + before(() => { + // Log environment configuration for debugging + cy.task('log', `Test Environment Configuration: + - APPFLOWY_BASE_URL: ${APPFLOWY_BASE_URL} + - APPFLOWY_GOTRUE_BASE_URL: ${APPFLOWY_GOTRUE_BASE_URL}`); + }); + + beforeEach(() => { + // Generate unique test data for each test + testEmail = generateRandomEmail(); + }); + + describe('Model Selection Persistence', () => { + it('should persist selected model after page reload', () => { + // Handle uncaught exceptions during workspace creation + cy.on('uncaught:exception', (err: Error) => { + if (err.message.includes('No workspace or service found')) { + return false; + } + if (err.message.includes('View not found')) { + return false; + } + if (err.message.includes('WebSocket') || err.message.includes('connection')) { + return false; + } + return true; + }); + + // Step 1: Login + cy.task('log', '=== Step 1: Login ==='); + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url().should('include', '/app'); + + // Wait for the app to fully load + cy.task('log', 'Waiting for app to fully load...'); + + // Wait for the loading screen to disappear and main app to appear + cy.get('body', { timeout: 30000 }).should('not.contain', 'Welcome!'); + + // Wait for the sidebar to be visible (indicates app is loaded) + SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); + + // Wait for at least one page to exist in the sidebar + PageSelectors.names().should('exist', { timeout: 30000 }); + + // Additional wait for stability + cy.wait(2000); + + // Step 2: Create an AI Chat + cy.task('log', '=== Step 2: Creating AI Chat ==='); + + // Expand the first space to see its pages + TestTool.expandSpace(); + cy.wait(1000); + + // Find the first page item and hover over it to show actions + PageSelectors.items().first().then($page => { + cy.task('log', 'Hovering over first page to show action buttons...'); + + // Hover over the page to reveal the action buttons + cy.wrap($page) + .trigger('mouseenter', { force: true }) + .trigger('mouseover', { force: true }); + + cy.wait(1000); + + // Click the inline add button (plus icon) + cy.wrap($page).within(() => { + cy.get('[data-testid="inline-add-page"]') + .first() + .should('be.visible') + .click({ force: true }); + }); + }); + + // Wait for the dropdown menu to appear + cy.wait(1000); + + // Click on the AI Chat option from the dropdown + cy.get('[data-testid="add-ai-chat-button"]') + .should('be.visible') + .click(); + + cy.task('log', 'Created AI Chat'); + + // Wait for navigation to the AI chat page + cy.wait(3000); + + // Step 3: Open model selector and select a model + cy.task('log', '=== Step 3: Selecting a Model ==='); + + // Wait for the chat interface to load + cy.wait(2000); + + // Click on the model selector button + ModelSelectorSelectors.button() + .should('be.visible', { timeout: 10000 }) + .click(); + + cy.task('log', 'Opened model selector dropdown'); + + // Wait for the dropdown to appear and models to load + cy.wait(2000); + + // Select a specific model (we'll select the first non-Auto model if available) + ModelSelectorSelectors.options() + .then($options => { + // Find a model that's not "Auto" + const nonAutoOptions = $options.filter((i, el) => { + const testId = el.getAttribute('data-testid'); + return testId && !testId.includes('model-option-Auto'); + }); + + if (nonAutoOptions.length > 0) { + // Click the first non-Auto model + const selectedModel = nonAutoOptions[0].getAttribute('data-testid')?.replace('model-option-', ''); + cy.task('log', `Selecting model: ${selectedModel}`); + cy.wrap(nonAutoOptions[0]).click(); + + // Store the selected model name for verification + cy.wrap(selectedModel).as('selectedModel'); + } else { + // If only Auto is available, select it explicitly + cy.task('log', 'Only Auto model available, selecting it'); + ModelSelectorSelectors.optionByName('Auto').click(); + cy.wrap('Auto').as('selectedModel'); + } + }); + + // Wait for the selection to be applied + cy.wait(1000); + + // Verify the model is selected by checking the button text + cy.get('@selectedModel').then((modelName) => { + cy.task('log', `Verifying model ${modelName} is displayed in button`); + ModelSelectorSelectors.button() + .should('contain.text', modelName); + }); + + // Step 4: Save the current URL for reload + cy.task('log', '=== Step 4: Saving current URL ==='); + cy.url().then(url => { + cy.wrap(url).as('chatUrl'); + cy.task('log', `Current chat URL: ${url}`); + }); + + // Step 5: Reload the page + cy.task('log', '=== Step 5: Reloading page ==='); + cy.reload(); + + // Wait for the page to reload completely + cy.wait(3000); + + // Step 6: Verify the model selection persisted + cy.task('log', '=== Step 6: Verifying Model Selection Persisted ==='); + + // Wait for the model selector button to be visible again + ModelSelectorSelectors.button() + .should('be.visible', { timeout: 10000 }); + + // Verify the previously selected model is still displayed + cy.get('@selectedModel').then((modelName) => { + cy.task('log', `Checking if model ${modelName} is still selected after reload`); + ModelSelectorSelectors.button() + .should('contain.text', modelName); + cy.task('log', `✓ Model ${modelName} persisted after page reload!`); + }); + + // Optional: Open the dropdown again to verify the selection visually + cy.task('log', '=== Step 7: Double-checking selection in dropdown ==='); + ModelSelectorSelectors.button().click(); + cy.wait(1000); + + // Verify the selected model has the selected styling + cy.get('@selectedModel').then((modelName) => { + ModelSelectorSelectors.optionByName(modelName as string) + .should('have.class', 'bg-fill-content-select'); + cy.task('log', `✓ Model ${modelName} shows as selected in dropdown`); + }); + + // Close the dropdown + cy.get('body').click(0, 0); + + // Final verification + cy.task('log', '=== Test completed successfully! ==='); + cy.task('log', '✓✓✓ Model selection persisted after page reload'); + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/support/selectors.ts b/cypress/support/selectors.ts index 6eada2d3..a5d5e089 100644 --- a/cypress/support/selectors.ts +++ b/cypress/support/selectors.ts @@ -184,6 +184,27 @@ export const SidebarSelectors = { pageHeader: () => cy.get(byTestId('sidebar-page-header')), }; +/** + * Chat Model Selector-related selectors + * Used for testing AI model selection in chat interface + */ +export const ModelSelectorSelectors = { + // Model selector button + button: () => cy.get(byTestId('model-selector-button')), + + // Model search input + searchInput: () => cy.get(byTestId('model-search-input')), + + // Get all model options + options: () => cy.get('[data-testid^="model-option-"]'), + + // Get specific model option by name + optionByName: (modelName: string) => cy.get(byTestId(`model-option-${modelName}`)), + + // Get selected model option (has the selected class) + selectedOption: () => cy.get('[data-testid^="model-option-"]').filter('.bg-fill-content-select'), +}; + /** * Database Grid-related selectors */ diff --git a/src/components/chat/chat/main.tsx b/src/components/chat/chat/main.tsx index efc131d7..2b34a7ba 100644 --- a/src/components/chat/chat/main.tsx +++ b/src/components/chat/chat/main.tsx @@ -1,47 +1,72 @@ // Code: Chat main component -import { ChatContext } from './context'; import { ChatInput } from '@/components/chat/components/chat-input'; import { ChatMessages } from '@/components/chat/components/chat-messages'; +import { ModelSelectorContext } from '@/components/chat/contexts/model-selector-context'; import { cn } from '@/components/chat/lib/utils'; -import { MessageAnimationProvider } from '@/components/chat/provider/message-animation-provider'; import { EditorProvider } from '@/components/chat/provider/editor-provider'; -import { MessagesHandlerProvider } from '@/components/chat/provider/messages-handler-provider'; +import { MessageAnimationProvider } from '@/components/chat/provider/message-animation-provider'; +import { MessagesHandlerProvider, useMessagesHandlerContext } from '@/components/chat/provider/messages-handler-provider'; import { ChatMessagesProvider } from '@/components/chat/provider/messages-provider'; import { PromptModalProvider } from '@/components/chat/provider/prompt-modal-provider'; import { ResponseFormatProvider } from '@/components/chat/provider/response-format-provider'; import { SelectionModeProvider } from '@/components/chat/provider/selection-mode-provider'; import { SuggestionsProvider } from '@/components/chat/provider/suggestions-provider'; -import { ChatProps } from '@/components/chat/types'; -import { AnimatePresence, motion } from 'framer-motion'; import { ViewLoaderProvider } from '@/components/chat/provider/view-loader-provider'; -import { ModelSelectorContext } from '@/components/chat/contexts/model-selector-context'; +import { ChatProps, User } from '@/components/chat/types'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ChatContext, useChatContext } from './context'; + +// Component to bridge ModelSelector with MessagesHandler +function ChatContentWithModelSync({ currentUser, selectionMode }: { currentUser?: User; selectionMode?: boolean }) { + const { selectedModelName, setSelectedModelName } = useMessagesHandlerContext(); + const { requestInstance, chatId } = useChatContext(); + + return ( + requestInstance.getModelList(), + getCurrentModel: async () => { + const settings = await requestInstance.getChatSettings(); + + return settings.metadata?.ai_model as string | undefined || ''; + }, + setCurrentModel: async (modelName: string) => { + await requestInstance.updateChatSettings({ + metadata: { + ai_model: modelName + } + }); + }, + }, + chatId, + }} + > +
+ + + + {!selectionMode && } + + +
+
+ ); +} function Main(props: ChatProps) { const { currentUser, selectionMode } = props; return ( - props.requestInstance.getModelList(), - getCurrentModel: async () => { - const settings = await props.requestInstance.getChatSettings(); - - return settings.metadata?.ai_model as string | undefined || ''; - }, - setCurrentModel: async (modelName: string) => { - await props.requestInstance.updateChatSettings({ - metadata: { - ai_model: modelName - } - }); - }, - }, - chatId: props.chatId, - }} - > - + @@ -61,19 +86,7 @@ function Main(props: ChatProps) { testDatabasePromptConfig={props.testDatabasePromptConfig} > -
- - - - {!selectionMode && } - - -
+
@@ -83,7 +96,6 @@ function Main(props: ChatProps) {
-
); } diff --git a/src/components/chat/components/chat-input/model-selector/index.tsx b/src/components/chat/components/chat-input/model-selector/index.tsx index ce988fff..a49d6d8e 100644 --- a/src/components/chat/components/chat-input/model-selector/index.tsx +++ b/src/components/chat/components/chat-input/model-selector/index.tsx @@ -208,6 +208,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) { className={cn('h-7 gap-1 px-2 text-xs font-normal text-text-secondary', className)} onMouseDown={(e) => e.preventDefault()} disabled={disabled} + data-testid="model-selector-button" title={hasContext ? 'Select AI Model' : 'Model selector (offline mode)'} > {AISparksIcon ? : 🤖} @@ -229,6 +230,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className='w-full bg-transparent px-2 py-1 text-sm outline-none placeholder:text-text-placeholder' + data-testid="model-search-input" onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); @@ -238,7 +240,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) { {/* Models List */} -
+
{loading ? (
Loading models...
) : filteredModels.length === 0 ? ( @@ -260,6 +262,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) { 'focus:bg-fill-content-hover focus:outline-none', isSelected && 'bg-fill-content-select' )} + data-testid={`model-option-${model.name}`} >
diff --git a/src/components/chat/provider/messages-handler-provider.tsx b/src/components/chat/provider/messages-handler-provider.tsx index bf570b98..e8094fef 100644 --- a/src/components/chat/provider/messages-handler-provider.tsx +++ b/src/components/chat/provider/messages-handler-provider.tsx @@ -39,7 +39,28 @@ export const MessagesHandlerContext = createContext(); + + // Load initial model from settings + useEffect(() => { + const loadModel = async () => { + try { + const settings = await requestInstance.getChatSettings(); + const model = settings.metadata?.ai_model as string | undefined; + + if (model) { + setSelectedModelName(model); + } + } catch (error) { + console.error('Failed to load chat model settings:', error); + } + }; + + if (chatId) { + void loadModel(); + } + }, [chatId, requestInstance]); const { messageIds, addMessages, insertMessage, removeMessages, saveMessageContent, getMessage } = useChatMessagesContext(); @@ -318,6 +339,20 @@ function useMessagesHandler() { } }, []); + // Update local state and persist to chat settings + const updateSelectedModel = useCallback(async (modelName: string) => { + setSelectedModelName(modelName); + try { + await requestInstance.updateChatSettings({ + metadata: { + ai_model: modelName + } + }); + } catch (error) { + console.error('Failed to update chat model settings:', error); + } + }, [requestInstance]); + return { fetchMessages, submitQuestion, @@ -327,7 +362,7 @@ function useMessagesHandler() { questionSending, answerApplying, selectedModelName, - setSelectedModelName, + setSelectedModelName: updateSelectedModel, }; }