chore: fix chat model select and add test

This commit is contained in:
nathan
2025-09-11 00:29:39 +08:00
parent 2bc21feba6
commit c3a6ea7972
5 changed files with 319 additions and 43 deletions

View 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');
});
});
});
});

View File

@@ -184,6 +184,27 @@ export const SidebarSelectors = {
pageHeader: () => cy.get(byTestId('sidebar-page-header')), 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 * Database Grid-related selectors
*/ */

View File

@@ -1,47 +1,72 @@
// Code: Chat main component // Code: Chat main component
import { ChatContext } from './context';
import { ChatInput } from '@/components/chat/components/chat-input'; import { ChatInput } from '@/components/chat/components/chat-input';
import { ChatMessages } from '@/components/chat/components/chat-messages'; 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 { cn } from '@/components/chat/lib/utils';
import { MessageAnimationProvider } from '@/components/chat/provider/message-animation-provider';
import { EditorProvider } from '@/components/chat/provider/editor-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 { ChatMessagesProvider } from '@/components/chat/provider/messages-provider';
import { PromptModalProvider } from '@/components/chat/provider/prompt-modal-provider'; import { PromptModalProvider } from '@/components/chat/provider/prompt-modal-provider';
import { ResponseFormatProvider } from '@/components/chat/provider/response-format-provider'; import { ResponseFormatProvider } from '@/components/chat/provider/response-format-provider';
import { SelectionModeProvider } from '@/components/chat/provider/selection-mode-provider'; import { SelectionModeProvider } from '@/components/chat/provider/selection-mode-provider';
import { SuggestionsProvider } from '@/components/chat/provider/suggestions-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 { 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) { function Main(props: ChatProps) {
const { currentUser, selectionMode } = props; const { currentUser, selectionMode } = props;
return ( return (
<ChatContext.Provider value={props}> <ChatContext.Provider value={props}>
<ModelSelectorContext.Provider <ChatMessagesProvider>
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>
<MessageAnimationProvider> <MessageAnimationProvider>
<SuggestionsProvider> <SuggestionsProvider>
<EditorProvider> <EditorProvider>
@@ -61,19 +86,7 @@ function Main(props: ChatProps) {
testDatabasePromptConfig={props.testDatabasePromptConfig} testDatabasePromptConfig={props.testDatabasePromptConfig}
> >
<MessagesHandlerProvider> <MessagesHandlerProvider>
<div className={'w-full relative h-full flex flex-col'}> <ChatContentWithModelSync currentUser={currentUser} selectionMode={selectionMode} />
<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>
</MessagesHandlerProvider> </MessagesHandlerProvider>
</PromptModalProvider> </PromptModalProvider>
</ResponseFormatProvider> </ResponseFormatProvider>
@@ -83,7 +96,6 @@ function Main(props: ChatProps) {
</SuggestionsProvider> </SuggestionsProvider>
</MessageAnimationProvider> </MessageAnimationProvider>
</ChatMessagesProvider> </ChatMessagesProvider>
</ModelSelectorContext.Provider>
</ChatContext.Provider> </ChatContext.Provider>
); );
} }

View File

@@ -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)} className={cn('h-7 gap-1 px-2 text-xs font-normal text-text-secondary', className)}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
disabled={disabled} disabled={disabled}
data-testid="model-selector-button"
title={hasContext ? 'Select AI Model' : 'Model selector (offline mode)'} title={hasContext ? 'Select AI Model' : 'Model selector (offline mode)'}
> >
{AISparksIcon ? <AISparksIcon className='h-5 w-5 text-icon-secondary' /> : <span className='text-[10px]'>🤖</span>} {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} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className='w-full bg-transparent px-2 py-1 text-sm outline-none placeholder:text-text-placeholder' className='w-full bg-transparent px-2 py-1 text-sm outline-none placeholder:text-text-placeholder'
data-testid="model-search-input"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
setOpen(false); setOpen(false);
@@ -238,7 +240,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) {
</div> </div>
{/* Models List */} {/* 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 ? ( {loading ? (
<div className='px-3 py-8 text-center text-sm text-text-secondary'>Loading models...</div> <div className='px-3 py-8 text-center text-sm text-text-secondary'>Loading models...</div>
) : filteredModels.length === 0 ? ( ) : filteredModels.length === 0 ? (
@@ -260,6 +262,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) {
'focus:bg-fill-content-hover focus:outline-none', 'focus:bg-fill-content-hover focus:outline-none',
isSelected && 'bg-fill-content-select' isSelected && 'bg-fill-content-select'
)} )}
data-testid={`model-option-${model.name}`}
> >
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>

View File

@@ -39,7 +39,28 @@ export const MessagesHandlerContext = createContext<MessagesHandlerContextTypes
function useMessagesHandler() { function useMessagesHandler() {
const { chatId, requestInstance, currentUser } = useChatContext(); const { chatId, requestInstance, currentUser } = useChatContext();
// Get the current model from chat settings
const [selectedModelName, setSelectedModelName] = useState<string>(); 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 } = const { messageIds, addMessages, insertMessage, removeMessages, saveMessageContent, getMessage } =
useChatMessagesContext(); 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 { return {
fetchMessages, fetchMessages,
submitQuestion, submitQuestion,
@@ -327,7 +362,7 @@ function useMessagesHandler() {
questionSending, questionSending,
answerApplying, answerApplying,
selectedModelName, selectedModelName,
setSelectedModelName, setSelectedModelName: updateSelectedModel,
}; };
} }