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,

Reply via email to