diff --git a/web/src/pages/home/__tests__/HomePage.test.tsx b/web/src/pages/home/__tests__/HomePage.test.tsx new file mode 100644 index 00000000..c2ce3cd2 --- /dev/null +++ b/web/src/pages/home/__tests__/HomePage.test.tsx @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { App } from 'antd'; +import { LangProvider } from '../../../i18n/LangContext'; +import HomePage from '../index'; + +const navigateMock = vi.hoisted(() => vi.fn()); +const llmApiMocks = vi.hoisted(() => ({ + getLlmConfig: vi.fn(), + getLlmModels: vi.fn(), +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock('../../../api/llm', () => llmApiMocks); + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +beforeEach(() => { + vi.clearAllMocks(); + llmApiMocks.getLlmConfig.mockResolvedValue({ + provider: 'openai', + apiBase: 'https://api.example.com/v1', + model: 'provider-model', + maxTokens: 4096, + temperature: 0.7, + enabled: true, + ready: true, + }); + llmApiMocks.getLlmModels.mockResolvedValue({ + status: 0, + data: [{ id: 'provider-model' }, { id: 'provider-model-secondary' }], + source: 'provider', + }); +}); + +const renderHome = () => + render( + + + + + , + ); + +describe('HomePage LLM models', () => { + it('loads configured provider models instead of hard-coded options', async () => { + renderHome(); + + expect(await screen.findByText('provider-model')).toBeInTheDocument(); + expect(screen.queryByText('qwen3.7-max')).not.toBeInTheDocument(); + }); + + it('submits the configured provider model to the AI page', async () => { + const user = userEvent.setup(); + renderHome(); + await screen.findByText('provider-model'); + + await user.type(screen.getByPlaceholderText('向 RocketMQ Bot 提问,全程加密、安全、可信'), '查看集群状态{enter}'); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith('/ai', { + state: { + prompt: '查看集群状态', + model: 'provider-model', + }, + }); + }); + }); +}); diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx index a83e3095..ae7735a8 100644 --- a/web/src/pages/home/index.tsx +++ b/web/src/pages/home/index.tsx @@ -32,6 +32,7 @@ import { MegaphoneSimple, Database, } from '@phosphor-icons/react'; +import { getLlmConfig, getLlmModels } from '../../api/llm'; import { useLang } from '../../i18n/LangContext'; /* ─── Time-aware greeting key ─── */ @@ -52,20 +53,18 @@ const modes = [ { key: 'chat', labelKey: 'home.mode.chat', icon: ChatCircleDots }, ]; -/* ─── Model option values ─── */ -const modelOptions = [ - { value: 'qwen3.7-max', recommended: true }, - { value: 'qwen3.7-plus', recommended: false }, - { value: 'claude-opus-4.7', recommended: false }, - { value: 'gpt-5.4', recommended: false }, -]; +interface ModelOption { + value: string; + recommended: boolean; +} /* ═══════════════════════════════════════════════════════ HomePage Component ═══════════════════════════════════════════════════════ */ const HomePage = () => { const [activeMode, setActiveMode] = useState('query'); - const [selectedModel, setSelectedModel] = useState('qwen3.7-max'); + const [modelOptions, setModelOptions] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); const [inputValue, setInputValue] = useState(''); const [indicatorStyle, setIndicatorStyle] = useState({ width: 83, left: 6 }); const textareaRef = useRef(null); @@ -73,6 +72,39 @@ const HomePage = () => { const navigate = useNavigate(); const { t, lang } = useLang(); + useEffect(() => { + let cancelled = false; + + const loadModels = async () => { + const [config, modelsResult] = await Promise.all([ + getLlmConfig().catch(() => null), + getLlmModels().catch(() => null), + ]); + if (cancelled) return; + + const configuredModel = config?.model?.trim() ?? ''; + const providerModels = modelsResult?.status === 0 && modelsResult.data + ? modelsResult.data.map((item) => item.id || item.name || '').filter(Boolean) + : []; + const values = Array.from(new Set([ + ...(configuredModel ? [configuredModel] : []), + ...providerModels, + ])); + + setModelOptions(values.map((value, index) => ({ + value, + recommended: configuredModel ? value === configuredModel : index === 0, + }))); + setSelectedModel((current) => (current && values.includes(current) ? current : values[0] || '')); + }; + + void loadModels(); + + return () => { + cancelled = true; + }; + }, []); + /* ─── Mode switch handler ─── */ const handleModeSwitch = useCallback((key: string, btn: HTMLButtonElement) => { setActiveMode(key); @@ -124,7 +156,7 @@ const HomePage = () => { const handlePromptSubmit = () => { const prompt = inputValue.trim(); - navigate('/ai', { state: prompt ? { prompt, model: selectedModel } : null }); + navigate('/ai', { state: prompt ? { prompt, ...(selectedModel ? { model: selectedModel } : {}) } : null }); }; return ( @@ -292,8 +324,11 @@ const HomePage = () => {