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 706f50891 fix(ai): isolate persisted conversations in Mock mode (#7128)
706f50891 is described below
commit 706f508917db3312884ebfc46c78495dc0f1a501
Author: zmuxuny <[email protected]>
AuthorDate: Tue Oct 27 03:27:40 2026 -0700
fix(ai): isolate persisted conversations in Mock mode (#7128)
Mock mode is a demo/offline surface, but it still loaded and mutated
persisted AI
conversations: a deep link to /ai/c/<id> attached a real conversation, the
history
drawer mounted its live query and delete actions, and a home-page draft was
auto-sent, creating a row in the real store.
Gate all three on the mode. The history modal takes a disabled prop and
renders
an InfoBanner explaining that saved conversations are unavailable in mock
mode
instead of mounting the panel, so no query or action is reachable; the draft
handoff takes sendEnabled and consumes the draft locally without creating a
conversation or sending it; and the deep link is not attached. Adds the
ai.mockConversationsUnavailable key in zh and en.
---
web/src/i18n/translations.ts | 4 +
web/src/pages/ai/__tests__/AiPage.test.tsx | 97 ++++++++++++++++++++++
.../pages/ai/components/ConversationListModal.tsx | 21 +++--
web/src/pages/ai/hooks/useDraftHandoff.ts | 11 ++-
web/src/pages/ai/index.tsx | 6 +-
5 files changed, 132 insertions(+), 7 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 3088a7b38..98773649f 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1843,6 +1843,10 @@ const translations: Record<string, Record<Lang, string>>
= {
zh: '切换到真实数据模式并配置 LLM Provider 后,才会加载模型、工具目录和对话能力。',
en: 'Models, the tool catalog and chat capabilities are loaded only after
you switch to real data mode and configure an LLM provider.',
},
+ 'ai.mockConversationsUnavailable': {
+ zh: 'Mock 模式不加载或修改已保存的 AI 会话,请切换到真实数据模式后使用。',
+ en: 'Saved AI conversations are not loaded or changed in mock mode; switch
to real data mode to use them.',
+ },
'ai.mockToolsUnavailable': {
zh: 'Mock 模式不加载 AI 工具目录,请切换到真实数据模式后使用。',
en: 'The AI tool catalog is not loaded in mock mode; switch to real data
mode to use it.',
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index efc3f7844..20890d1c2 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from
'react-router-dom';
import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
import { attachRunStream, executeTool, listTools, openRunStream } from
'../../../api/ai';
import {
createConversation,
@@ -716,6 +717,102 @@ describe('AiPage', () => {
expect(getLlmModels).not.toHaveBeenCalled();
});
+ it.each([
+ { fromHome: false, lang: 'zh', title: 'AI 对话历史' },
+ { fromHome: true, lang: 'zh', title: 'AI 对话历史' },
+ { fromHome: false, lang: 'en', title: 'AI conversation history' },
+ { fromHome: true, lang: 'en', title: 'AI conversation history' },
+ ])(
+ 'doesNotLoadRealConversationHistoryInMockModeTest ($lang, route intent:
$fromHome)',
+ async ({ fromHome, lang, title }) => {
+ dataModeMocks.useMock = true;
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+ vi.mocked(listConversations).mockResolvedValue({
+ items: [CONVERSATION_7],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ renderPage(fromHome ? { historyIntent: 'open' } : undefined);
+ if (!fromHome) {
+ await userEvent.setup().click(screen.getByRole('button', { name: title
}));
+ }
+ const dialog = await screen.findByRole('dialog', { name: title });
+ await act(async () => {});
+
+ expect(listConversations).not.toHaveBeenCalled();
+
expect(within(dialog).queryByTestId('ai-conversation-link-7')).not.toBeInTheDocument();
+
expect(within(dialog).queryByTestId('ai-conversation-page-delete')).not.toBeInTheDocument();
+
expect(within(dialog).getByTestId('ai-mock-history-disabled')).toHaveTextContent(
+ lang === 'zh'
+ ? 'Mock 模式不加载或修改已保存的 AI 会话,请切换到真实数据模式后使用。'
+ : 'Saved AI conversations are not loaded or changed in mock mode;
switch to real data mode to use them.',
+ );
+ },
+ );
+
+ it('doesNotReadOrAttachARealConversationDeepLinkInMockModeTest', async () =>
{
+ dataModeMocks.useMock = true;
+ vi.mocked(getConversationTimeline).mockResolvedValue(
+ timelinePage([item(1, { type: 'text', text: 'real conversation content'
})], {
+ id: 41,
+ status: 'RUNNING',
+ }),
+ );
+ vi.mocked(attachRunStream).mockReturnValue(new Promise(() => {}));
+ renderRouted('/ai/c/7');
+ await screen.findByTestId('ai-mock-disabled');
+ await act(async () => {});
+
+ expect.soft(getConversationTimeline).not.toHaveBeenCalled();
+ expect.soft(attachRunStream).not.toHaveBeenCalled();
+ expect(screen.queryByText('real conversation
content')).not.toBeInTheDocument();
+
expect(screen.getByTestId('ai-send-stop-button')).toHaveAttribute('data-state',
'send');
+ expect(stopRun).not.toHaveBeenCalled();
+ });
+
+ it.each(['/ai', '/ai/c/7'])(
+ 'keepsTheHomeDraftWithoutCreatingARealConversationInMockModeTest (%s)',
+ async (path) => {
+ dataModeMocks.useMock = true;
+ renderRouted(path, { prompt: 'Keep this mock draft', mode: 'diagnose',
enhance: true });
+ await waitFor(() => expect(readProbeLocation().state).toBeNull());
+
+ expect.soft(createConversation).not.toHaveBeenCalled();
+ expect.soft(listInstances).not.toHaveBeenCalled();
+ expect.soft(openRunStream).not.toHaveBeenCalled();
+ expect.soft(readProbeLocation().pathname).toBe(path);
+ expect(screen.getByPlaceholderText(PLACEHOLDER)).toHaveValue('Keep this
mock draft');
+ expect(sessionStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBe('Keep
this mock draft');
+ },
+ );
+
+ it('onlySendsTheRetainedMockDraftAfterManualSubmitInRealModeTest', async ()
=> {
+ dataModeMocks.useMock = true;
+ const view = renderPage({ prompt: 'Submit this after switching to real
mode' });
+ await waitFor(() => expect(readProbeLocation().state).toBeNull());
+ expect(createConversation).not.toHaveBeenCalled();
+ view.unmount();
+
+ // MainLayout changes modes with a full reload. The draft survives it
locally, but its
+ // consumed route state must not silently turn that reload into a live
backend operation.
+ dataModeMocks.useMock = false;
+ vi.mocked(openRunStream).mockReturnValue(new Promise(() => {}));
+ renderPage();
+ await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+ const input = screen.getByPlaceholderText(PLACEHOLDER);
+ expect(input).toHaveValue('Submit this after switching to real mode');
+ expect(createConversation).not.toHaveBeenCalled();
+ expect(openRunStream).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await waitFor(() => expect(openRunStream).toHaveBeenCalledTimes(1));
+ expect(createConversation).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(openRunStream).mock.calls[0][1].message).toBe(
+ 'Submit this after switching to real mode',
+ );
+ });
+
it('doesNotLoadTheLlmRuntimeInMockModeAndDisablesTheComposerTest', async ()
=> {
dataModeMocks.useMock = true;
const user = userEvent.setup();
diff --git a/web/src/pages/ai/components/ConversationListModal.tsx
b/web/src/pages/ai/components/ConversationListModal.tsx
index 07db73e0a..4a10e54f0 100644
--- a/web/src/pages/ai/components/ConversationListModal.tsx
+++ b/web/src/pages/ai/components/ConversationListModal.tsx
@@ -38,6 +38,7 @@ import { useConversationList } from
'../hooks/useConversationList';
import { formatUtcDateTime } from '../../../utils/format';
import { tableScrollX } from '../../../utils/table';
import ModelBadge from './ModelBadge';
+import InfoBanner from '../../../components/InfoBanner';
/**
* Server-paged conversation history.
@@ -148,6 +149,8 @@ const CHAT_MODE_KEYS = new Set(['chat', 'diagnose',
'manage', 'query']);
export interface ConversationListModalProps {
open: boolean;
+ /** Mock mode: explain why history is unavailable without mounting its live
query or actions. */
+ disabled?: boolean;
onClose: () => void;
/** Conversation on screen; marked as current and not offered as a
navigation target. */
activeConversationId?: number | null;
@@ -514,6 +517,7 @@ const ConversationListPanel = ({
const ConversationListModal = ({
open,
onClose,
+ disabled = false,
activeConversationId = null,
onSelect,
onActiveDeleted,
@@ -547,11 +551,18 @@ const ConversationListModal = ({
width={TABLE_SCROLL_X + MODAL_BODY_PADDING + VERTICAL_SCROLLBAR_WIDTH}
destroyOnHidden
>
- <ConversationListPanel
- activeConversationId={activeConversationId}
- onSelect={onSelect}
- onDeleted={handleDeleted}
- />
+ {disabled ? (
+ <InfoBanner
+ data-testid="ai-mock-history-disabled"
+ description={t('ai.mockConversationsUnavailable')}
+ />
+ ) : (
+ <ConversationListPanel
+ activeConversationId={activeConversationId}
+ onSelect={onSelect}
+ onDeleted={handleDeleted}
+ />
+ )}
</Modal>
);
};
diff --git a/web/src/pages/ai/hooks/useDraftHandoff.ts
b/web/src/pages/ai/hooks/useDraftHandoff.ts
index 50f3991f8..3787f91b1 100644
--- a/web/src/pages/ai/hooks/useDraftHandoff.ts
+++ b/web/src/pages/ai/hooks/useDraftHandoff.ts
@@ -41,6 +41,8 @@ import type { StartRunOptions } from './useAiSend';
*/
export interface UseDraftHandoffOptions {
+ /** False consumes drafts locally without creating a conversation or sending
to the backend. */
+ sendEnabled?: boolean;
/** Write the draft into the composer state (text, model, engine, mode,
enhance). */
applyDraft: (draft: ChatDraft) => void;
/** The message request the draft auto-sends once its conversation is
resolved. */
@@ -92,13 +94,20 @@ export function useDraftHandoff(
consumedDraftRef.current = true;
void (async () => {
- const { applyDraft, buildDraftRequest, openHistory, startRun } =
optionsRef.current;
+ const { applyDraft, buildDraftRequest, openHistory, sendEnabled,
startRun } =
+ optionsRef.current;
if (wantsHistory) openHistory();
if (!draft) {
navigate(location.pathname, { replace: true, state: null });
return;
}
applyDraft(draft);
+ if (sendEnabled === false) {
+ // Mock handoffs still open the composer, but must neither mutate real
conversations nor
+ // clear the unsent draft. Strip the intent so switching to Real
cannot auto-submit it.
+ navigate(location.pathname, { replace: true, state: null });
+ return;
+ }
const start: StartRunOptions = {
createBody: { instanceId: draft.instanceId ?? null, mode: draft.mode
?? 'chat' },
request: buildDraftRequest(draft),
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index be20c485e..bdfd2831f 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -62,8 +62,10 @@ const AiPage = () => {
const { t } = useLang();
const { token } = theme.useToken();
const navigate = useNavigate();
- const conversationId = parseConversationId(useParams().conversationId);
+ const routeConversationId = parseConversationId(useParams().conversationId);
const useMock = useDataModeStore((state) => state.useMock);
+ // Persisted conversations and active runs are real backend resources, even
on a Mock deep link.
+ const conversationId = useMock ? null : routeConversationId;
const userId = useAuthStore((state) => state.userId);
const admin = useAuthStore((state) => state.admin);
const engine = useEngineStore((state) => state.engine);
@@ -101,6 +103,7 @@ const AiPage = () => {
});
useDraftHandoff(conversationId, {
+ sendEnabled: !useMock,
applyDraft: (draft) => {
setInputValue(draft.prompt);
if (draft.enhance !== undefined) setEnhance(draft.enhance);
@@ -344,6 +347,7 @@ const AiPage = () => {
/>
<ConversationListModal
open={overlay === 'history'}
+ disabled={useMock}
onClose={() => setOverlay(null)}
activeConversationId={conversationId}
onSelect={(id) => {