mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-29 19:08:33 +08:00
chore: fix chat model select and add test
This commit is contained in:
205
cypress/e2e/chat/model-selection-persistence.cy.ts
Normal file
205
cypress/e2e/chat/model-selection-persistence.cy.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 (
|
||||
<ModelSelectorContext.Provider
|
||||
value={{
|
||||
selectedModelName,
|
||||
setSelectedModelName,
|
||||
requestInstance: {
|
||||
getModelList: () => 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,
|
||||
}}
|
||||
>
|
||||
<div className={'w-full relative h-full flex flex-col'}>
|
||||
<ChatMessages currentUser={currentUser} />
|
||||
<motion.div
|
||||
layout
|
||||
className={cn(
|
||||
'w-full relative flex pb-6 justify-center max-sm:hidden',
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode='wait'>
|
||||
{!selectionMode && <ChatInput />}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</ModelSelectorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Main(props: ChatProps) {
|
||||
const { currentUser, selectionMode } = props;
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={props}>
|
||||
<ModelSelectorContext.Provider
|
||||
value={{
|
||||
requestInstance: {
|
||||
getModelList: () => 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,
|
||||
}}
|
||||
>
|
||||
<ChatMessagesProvider>
|
||||
<ChatMessagesProvider>
|
||||
<MessageAnimationProvider>
|
||||
<SuggestionsProvider>
|
||||
<EditorProvider>
|
||||
@@ -61,19 +86,7 @@ function Main(props: ChatProps) {
|
||||
testDatabasePromptConfig={props.testDatabasePromptConfig}
|
||||
>
|
||||
<MessagesHandlerProvider>
|
||||
<div className={'w-full relative h-full flex flex-col'}>
|
||||
<ChatMessages currentUser={currentUser} />
|
||||
<motion.div
|
||||
layout
|
||||
className={cn(
|
||||
'w-full relative flex pb-6 justify-center max-sm:hidden',
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode='wait'>
|
||||
{!selectionMode && <ChatInput />}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
<ChatContentWithModelSync currentUser={currentUser} selectionMode={selectionMode} />
|
||||
</MessagesHandlerProvider>
|
||||
</PromptModalProvider>
|
||||
</ResponseFormatProvider>
|
||||
@@ -83,7 +96,6 @@ function Main(props: ChatProps) {
|
||||
</SuggestionsProvider>
|
||||
</MessageAnimationProvider>
|
||||
</ChatMessagesProvider>
|
||||
</ModelSelectorContext.Provider>
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? <AISparksIcon className='h-5 w-5 text-icon-secondary' /> : <span className='text-[10px]'>🤖</span>}
|
||||
@@ -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) {
|
||||
</div>
|
||||
|
||||
{/* Models List */}
|
||||
<div className='max-h-[380px] overflow-y-auto py-1'>
|
||||
<div className='appflowy-scrollbar max-h-[380px] overflow-y-auto py-1'>
|
||||
{loading ? (
|
||||
<div className='px-3 py-8 text-center text-sm text-text-secondary'>Loading models...</div>
|
||||
) : 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}`}
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
|
||||
@@ -39,7 +39,28 @@ export const MessagesHandlerContext = createContext<MessagesHandlerContextTypes
|
||||
function useMessagesHandler() {
|
||||
const { chatId, requestInstance, currentUser } = useChatContext();
|
||||
|
||||
// Get the current model from chat settings
|
||||
const [selectedModelName, setSelectedModelName] = useState<string>();
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user