This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new ca348bdb6 fix(ai): preserve handoff models when the catalog loads
(#6274)
ca348bdb6 is described below
commit ca348bdb6755a71083bc57203fcd2380c188d245
Author: bai mengxuan <[email protected]>
AuthorDate: Tue Oct 27 14:22:00 2026 +0800
fix(ai): preserve handoff models when the catalog loads (#6274)
The model chosen on the home page was merged into the same state that the
catalog load replaces, so a catalog response arriving after the handoff
silently dropped it: the AI menu no longer offered the model the composer
said was selected.
Keep handoff-added options in their own state and expose the union, with the
catalog entry winning on duplicates, so a late catalog load can no longer
discard them. Disabling the runtime clears both.
---
web/src/pages/ai/__tests__/AiPage.test.tsx | 100 ++++++++++++++++++++++++++-
web/src/pages/ai/hooks/useLlmRuntime.test.ts | 41 ++++++++++-
web/src/pages/ai/hooks/useLlmRuntime.ts | 17 ++++-
3 files changed, 153 insertions(+), 5 deletions(-)
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index ac6cce229..efc3f7844 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -43,6 +43,7 @@ import useAuthStore from '../../../stores/authStore';
import { useEngineStore } from '../../../stores/engineStore';
import AiPage from '../index';
import { COMPOSER_DRAFT_STORAGE_KEY } from '../hooks/useComposerDraft';
+import HomePage from '../../home';
/**
* The page shell, end to end: route param in, hooks wired, `ChatThread` +
`Composer` out.
@@ -94,7 +95,10 @@ vi.mock('../../../services/instanceService', () => ({
}));
vi.mock('../../../stores/dataModeStore', () => ({
- useDataModeStore: (selector: (state: typeof dataModeMocks) => unknown) =>
selector(dataModeMocks),
+ useDataModeStore: Object.assign(
+ (selector: (state: typeof dataModeMocks) => unknown) =>
selector(dataModeMocks),
+ { getState: () => dataModeMocks },
+ ),
}));
beforeAll(() => {
@@ -498,6 +502,100 @@ describe('AiPage', () => {
expect(document.querySelector('.ant-alert-warning')).toBeNull();
});
+ it('homeModelRemainsSelectableAfterCatalogLoadTest', async () => {
+ vi.mocked(getLlmConfig).mockResolvedValue({
+ provider: 'tongyi',
+ engine: 'http',
+ apiBase:
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
+ model: 'qwen-plus',
+ maxTokens: 1024,
+ temperature: 0.2,
+ enabled: true,
+ ready: true,
+ });
+ let resolveModels!: (models: Awaited<ReturnType<typeof getLlmModels>>) =>
void;
+ vi.mocked(getLlmModels).mockReturnValue(
+ new Promise((resolve) => {
+ resolveModels = resolve;
+ }),
+ );
+ vi.mocked(openRunStream).mockResolvedValue(undefined);
+ useEngineStore.setState({ engine: 'http' });
+ const user = userEvent.setup();
+ render(
+ <App>
+ <LangProvider>
+ <MemoryRouter initialEntries={['/']}>
+ <LocationProbe />
+ <Routes>
+ <Route path="/" element={<HomePage />} />
+ <Route path="/ai" element={<AiPage />} />
+ <Route path="/ai/c/:conversationId" element={<AiPage />} />
+ </Routes>
+ </MemoryRouter>
+ </LangProvider>
+ </App>,
+ );
+
+ await screen.findByText('qwen3.8-max');
+ const homeSelector = screen.getByRole('combobox', { name: '模型名称' });
+ await user.click(homeSelector);
+ // Home appends the configured model after its fixed list; use the real
+ // selector's upward navigation from the selected first option to reach it.
+ fireEvent.keyDown(homeSelector, { key: 'ArrowUp', code: 'ArrowUp',
keyCode: 38, which: 38 });
+ await user.click(
+ await screen.findByText('qwen-plus', { selector:
'.ant-select-item-option-content span' }),
+ );
+ await waitFor(() =>
expect(homeSelector.closest('.ant-select')).toHaveTextContent('qwen-plus'));
+ await user.type(
+ screen.getByPlaceholderText('向 RocketMQ Bot 提问,全程加密、安全、可信'),
+ '查看集群状态{Enter}',
+ );
+ await waitFor(() => expect(readProbeLocation().pathname).toBe('/ai/c/7'));
+ await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+
+ // The saved-provider service test permits qwen-plus, while its actual
Tongyi
+ // catalog contains these twelve IDs rather than the configured model.
+ await act(async () =>
+ resolveModels({
+ status: 0,
+ source: 'builtin',
+ data: [
+ 'qwen3.8-max',
+ 'qwen3.7-max',
+ 'qwen3.7-plus',
+ 'gpt-5',
+ 'gpt-5.1',
+ 'claude-fable-5',
+ 'claude-opus-5',
+ 'claude-sonnet-5',
+ 'deepseek-v4-pro',
+ 'deepseek-v4-flash',
+ 'MiniMax-M2.5',
+ 'glm-5.2',
+ ].map((id) => ({ id })),
+ }),
+ );
+ await waitFor(() => expect(openRunStream).toHaveBeenCalledTimes(1));
+ expect(openRunStream).toHaveBeenCalledWith(
+ 7,
+ expect.objectContaining({ model: 'qwen-plus' }),
+ expect.anything(),
+ expect.anything(),
+ );
+ const selector = screen.getByRole('combobox', { name: '模型名称' });
+ await user.click(selector);
+ await user.click(
+ await screen.findByText('qwen3.8-max', { selector:
'.ant-select-item-option-content span' }),
+ );
+ await user.click(selector);
+ await user.click(
+ await screen.findByText('qwen-plus', { selector:
'.ant-select-item-option-content span' }),
+ );
+ expect(selector.closest('.ant-select')).toHaveTextContent('qwen-plus');
+ expect(openRunStream).toHaveBeenCalledTimes(1);
+ });
+
it('handsOffTheHomePageDraftIntoANewConversationAndAutoSendsItExactlyOnceTest',
async () => {
vi.mocked(openRunStream).mockResolvedValue(undefined);
diff --git a/web/src/pages/ai/hooks/useLlmRuntime.test.ts
b/web/src/pages/ai/hooks/useLlmRuntime.test.ts
index f41ca07b8..21613363d 100644
--- a/web/src/pages/ai/hooks/useLlmRuntime.test.ts
+++ b/web/src/pages/ai/hooks/useLlmRuntime.test.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
import { StrictMode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LlmConfig, LlmModelsResult } from '../../../api/llm';
@@ -68,6 +68,45 @@ describe('useLlmRuntime', () => {
vi.clearAllMocks();
});
+ it.each([{ catalog: [] }, { catalog: [{ id: 'gpt-test' }] }])(
+ 'preservesHandoffModelOptionsWhenCatalogCompletesTest: %j',
+ async ({ catalog }) => {
+ const pending = deferred<LlmModelsResult>();
+ configMock.mockResolvedValue(config);
+ modelsMock.mockReturnValue(pending.promise);
+ const { result, rerender } = render(true);
+ await waitFor(() => expect(modelsMock).toHaveBeenCalledTimes(1));
+ act(() => result.current.selectModel('handoff-model'));
+
+ await act(async () => pending.resolve({ status: 0, data: catalog }));
+ expect(result.current.modelOptions).toContainEqual({
+ value: 'handoff-model',
+ label: 'handoff-model',
+ });
+ expect(result.current.selectedModel).toBe('handoff-model');
+ expect(result.current.modelOptions).toContainEqual({ value: 'gpt-test',
label: 'gpt-test' });
+
+ act(() => result.current.setSelectedModel('gpt-test'));
+ expect(result.current.modelOptions.some((option) => option.value ===
'handoff-model')).toBe(
+ true,
+ );
+ rerender({ enabled: false });
+ expect(result.current.modelOptions).toEqual([]);
+ expect(result.current.selectedModel).toBe('');
+ },
+ );
+
+ it('doesNotDuplicateHandoffModelAlreadyInCatalogTest', async () => {
+ const pending = deferred<LlmModelsResult>();
+ configMock.mockResolvedValue(config);
+ modelsMock.mockReturnValue(pending.promise);
+ const { result } = render(true);
+ await waitFor(() => expect(modelsMock).toHaveBeenCalledTimes(1));
+ act(() => result.current.selectModel('gpt-test'));
+ await act(async () => pending.resolve({ status: 0, data: [{ id: 'gpt-test'
}] }));
+ expect(result.current.modelOptions).toEqual([{ value: 'gpt-test', label:
'gpt-test' }]);
+ });
+
it('ignoresConfigCompletionAndDoesNotLoadModelsAfterUnmountTest', async ()
=> {
const pending = deferred<LlmConfig>();
configMock.mockReturnValue(pending.promise);
diff --git a/web/src/pages/ai/hooks/useLlmRuntime.ts
b/web/src/pages/ai/hooks/useLlmRuntime.ts
index 8de7e28da..4532bd672 100644
--- a/web/src/pages/ai/hooks/useLlmRuntime.ts
+++ b/web/src/pages/ai/hooks/useLlmRuntime.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getLlmConfig, getLlmModels, type LlmConfig } from '../../../api/llm';
import type { AgentEngine } from '../../../stores/engineStore';
@@ -68,6 +68,9 @@ export function useLlmRuntime(options: UseLlmRuntimeOptions):
UseLlmRuntimeResul
const { enabled } = options;
const [config, setConfig] = useState<LlmConfig | null>(null);
const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
+ // A catalog load can finish after the home-page draft was applied. Keep its
+ // added options separate so replacing the catalog does not discard them.
+ const [handoffModelOptions, setHandoffModelOptions] =
useState<ModelOption[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState('');
@@ -80,7 +83,7 @@ export function useLlmRuntime(options: UseLlmRuntimeOptions):
UseLlmRuntimeResul
const selectModel = useCallback((model: string) => {
setSelectedModel(model);
- setModelOptions((current) =>
+ setHandoffModelOptions((current) =>
current.some((option) => option.value === model)
? current
: [{ value: model, label: model }, ...current],
@@ -96,6 +99,7 @@ export function useLlmRuntime(options: UseLlmRuntimeOptions):
UseLlmRuntimeResul
if (!enabled) {
setConfig(null);
setModelOptions([]);
+ setHandoffModelOptions([]);
setSelectedModel('');
setModelsLoading(false);
return;
@@ -137,10 +141,17 @@ export function useLlmRuntime(options:
UseLlmRuntimeOptions): UseLlmRuntimeResul
}, [load]);
const llmReady = Boolean((config?.ready ?? config?.enabled) &&
selectedModel);
+ const selectableModelOptions = useMemo(() => {
+ const catalogIds = new Set(modelOptions.map((option) => option.value));
+ return [
+ ...handoffModelOptions.filter((option) => !catalogIds.has(option.value)),
+ ...modelOptions,
+ ];
+ }, [handoffModelOptions, modelOptions]);
return {
config,
- modelOptions,
+ modelOptions: selectableModelOptions,
modelsLoading,
selectedModel,
llmReady,