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 3a5792ea6 fix(ai): stabilize workspace, bound conversation history and 
harden persistence (#2309)
3a5792ea6 is described below

commit 3a5792ea60001bff5a5f465e2fbf98b8fdf059f2
Author: aias00 <[email protected]>
AuthorDate: Tue Aug 18 20:21:05 2026 +0800

    fix(ai): stabilize workspace, bound conversation history and harden 
persistence (#2309)
    
    * fix: stabilize Mock mode and AI workspace
    
    * feat(ai): preserve bounded conversation history
    
    * fix(ai): harden conversation history persistence
---
 .../studio/ops/ai/tool/ToolGatewayService.java     |  28 +-
 .../studio/ops/ai/tool/ToolGatewayServiceTest.java |   9 +
 web/src/api/client.ts                              |   2 +
 web/src/i18n/translations.ts                       |  27 ++
 web/src/index.css                                  |  62 ++++
 web/src/layouts/MainLayout.tsx                     |   2 +
 web/src/pages/ai/__tests__/AiMessage.test.tsx      |   1 +
 web/src/pages/ai/__tests__/AiPage.test.tsx         | 169 +++++++++
 web/src/pages/ai/chatDraft.ts                      |  13 +-
 web/src/pages/ai/index.tsx                         | 394 ++++++++++++++++-----
 web/src/pages/settings/AiAssistantTab.tsx          |   7 +
 .../settings/__tests__/AiAssistantTab.test.tsx     |  21 ++
 web/src/stores/aiChatHistoryStore.test.ts          | 134 +++++++
 web/src/stores/aiChatHistoryStore.ts               | 273 ++++++++++++++
 web/src/utils/format.test.ts                       |  16 +-
 web/src/utils/format.ts                            |  35 ++
 16 files changed, 1094 insertions(+), 99 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayService.java
index 1022b802f..8e6d3a761 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayService.java
@@ -70,18 +70,36 @@ public class ToolGatewayService {
 
     public List<AiToolVO> discover(String clusterId) {
         boolean clusterSelected = clusterId != null && !clusterId.isBlank();
-        Set<String> capabilities = clusterSelected
-                ? Set.copyOf(capabilityResolver.resolve(clusterId))
-                : Collections.emptySet();
+        DiscoveryCapabilities discoveryCapabilities = 
resolveDiscoveryCapabilities(clusterId, clusterSelected);
 
         return catalog.list().stream()
-                .filter(definition -> clusterSelected || 
!requiresCluster(definition))
-                .filter(definition -> capabilities.containsAll(
+                .filter(definition -> 
discoveryCapabilities.clusterCapabilitiesResolved()
+                        || !requiresCluster(definition))
+                .filter(definition -> 
discoveryCapabilities.capabilities().containsAll(
                         definition.requiredCapabilities()))
                 .map(ToolGatewayService::toView)
                 .toList();
     }
 
+    /**
+     * A tool directory must remain available when the selected cluster cannot 
report its
+     * architecture. In that case expose only tools that do not require 
cluster capabilities;
+     * execution still resolves capabilities and returns the original 
diagnostic error.
+     */
+    private DiscoveryCapabilities resolveDiscoveryCapabilities(String 
clusterId, boolean clusterSelected) {
+        if (!clusterSelected) {
+            return new DiscoveryCapabilities(false, Collections.emptySet());
+        }
+        try {
+            return new DiscoveryCapabilities(true, 
Set.copyOf(capabilityResolver.resolve(clusterId)));
+        } catch (BusinessException ignored) {
+            return new DiscoveryCapabilities(false, Collections.emptySet());
+        }
+    }
+
+    private record DiscoveryCapabilities(boolean clusterCapabilitiesResolved, 
Set<String> capabilities) {
+    }
+
     public Object execute(String name, Map<String, Object> input) {
         ToolDefinition definition = catalog.find(name)
                 .orElseThrow(() -> new BusinessException(404, "Tool not found: 
" + name));
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
index 15f2d2c00..78a9cefb2 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
@@ -136,6 +136,15 @@ class ToolGatewayServiceTest {
                         "rmq.nameserver.config.diff");
     }
 
+    @Test
+    void discoveryWithClusterOfUnknownTypeOnlyExposesGlobalTools() {
+        
when(clusterService.getCluster("unknown")).thenReturn(cluster("unknown", null));
+
+        assertThat(gateway.discover("unknown"))
+                .extracting(AiToolVO::getName)
+                .containsExactly("rmq.cluster.list");
+    }
+
     @Test
     void executesNameServerConfigDiffWithAValidatedOutputContract() {
         when(clusterService.getCluster("cluster-v5"))
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 668ca8915..5d379f2cd 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -18,6 +18,7 @@
 import axios from 'axios';
 import { message } from 'antd';
 import { clearAuthSession, TOKEN_STORAGE_KEY } from '../stores/authStorage';
+import { clearAiChatHistories } from '../stores/aiChatHistoryStore';
 import { API_BASE_URL } from '../config';
 
 const SUCCESS_BUSINESS_CODES = new Set([0, 200]);
@@ -88,6 +89,7 @@ client.interceptors.response.use(
   },
   (error) => {
     if (error.response?.status === 401 && 
!isPublicAuthRequest(error.config?.url)) {
+      clearAiChatHistories();
       clearAuthSession();
       window.location.href = '/';
     }
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 3b7f4d21a..edfbab170 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -322,6 +322,33 @@ const translations: Record<string, Record<Lang, string>> = 
{
   // ─── AI Page ───
   'ai.title': { zh: 'AI 交互', en: 'AI Chat' },
   'ai.commonCommands': { zh: '常用指令', en: 'Common Commands' },
+  'ai.mockProviderDisabled': { zh: 'Mock 模式已禁用 AI Provider 调用', en: 'Mock mode 
disables AI provider calls' },
+  'ai.mockProviderDisabledDescription': {
+    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.mockToolsUnavailable': {
+    zh: 'Mock 模式不加载 AI 工具目录,请切换到真实数据模式后使用。',
+    en: 'The AI tool catalog is not loaded in mock mode; switch to real data 
mode to use it.',
+  },
+  'ai.modelsRefreshFailedAfterSave': {
+    zh: '配置已保存,但模型列表刷新失败;请稍后重试',
+    en: 'Configuration saved, but refreshing the model list failed; please 
retry later',
+  },
+  'ai.history.title': { zh: 'AI 对话历史', en: 'AI conversation history' },
+  'ai.history.empty': { zh: '当前模式暂无对话记录', en: 'No conversations in this mode' 
},
+  'ai.history.justNow': { zh: '刚刚', en: 'Just now' },
+  'ai.history.minutesAgo': { zh: '{count} 分钟前', en: '{count} min ago' },
+  'ai.responseStopped': { zh: '回答已停止。', en: 'Response stopped.' },
+  'ai.requestFailed': { zh: 'AI 请求失败', en: 'AI request failed' },
+  'ai.runtimeLoadFailed': { zh: 'AI 配置加载失败', en: 'Failed to load AI 
configuration' },
+  'ai.providerRequired': { zh: '请先配置并启用 LLM Provider', en: 'Configure and 
enable an LLM provider first' },
+  'ai.providerNotReadyDescription': {
+    zh: '请先在 设置 → AI 助手 中配置并启用 LLM Provider,启用前不会发送请求或返回 stub 回复。',
+    en: 'Configure and enable an LLM provider under Settings → AI Assistant 
first. No requests are sent and stub replies may be returned until it is 
enabled.',
+  },
+  'ai.toolCatalogLoadFailed': { zh: 'AI 工具目录加载失败', en: 'Failed to load the AI 
tool catalog' },
+  'ai.clusterListLoadFailed': { zh: '集群列表加载失败,已显示全局工具', en: 'Failed to load 
clusters; showing global tools' },
 
   // ─── Home Page ───
   'home.banner': {
diff --git a/web/src/index.css b/web/src/index.css
index e1384f638..892e1ba78 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -208,6 +208,68 @@ body {
   color: #7c3aed;
 }
 
+/* === AI page theme surface === */
+.ai-page .ai-chat-panel {
+  background: color-mix(in srgb, var(--ai-surface) 92%, transparent);
+  border-color: var(--ai-border);
+  box-shadow: 0 20px 60px -20px color-mix(in srgb, var(--ai-text-secondary) 
30%, transparent);
+}
+.ai-page .ai-chat-toolbar {
+  border-color: var(--ai-border);
+}
+.ai-page .chat-input {
+  color: var(--ai-text);
+}
+.ai-page .chat-input::placeholder {
+  color: var(--ai-text-tertiary);
+}
+.ai-page .tool-btn {
+  background: var(--ai-fill-secondary);
+  color: var(--ai-text);
+}
+.ai-page .tool-btn:hover {
+  background: var(--ai-primary-bg);
+  color: var(--ai-primary);
+}
+.ai-page .ai-send-button {
+  background: var(--ai-primary);
+}
+.ai-page .ai-send-button:hover {
+  background: var(--ai-primary-hover);
+}
+.ai-page .ai-markdown {
+  color: var(--ai-text);
+}
+.ai-page .ai-markdown h1,
+.ai-page .ai-markdown h2,
+.ai-page .ai-markdown h3,
+.ai-page .ai-markdown h4 {
+  color: var(--ai-text);
+}
+.ai-page .ai-markdown code {
+  background: var(--ai-fill-secondary);
+  color: var(--ai-primary);
+}
+.ai-page .ai-markdown pre {
+  background: var(--ai-code-bg);
+  border: 1px solid var(--ai-border);
+}
+.ai-page .ai-markdown pre code {
+  background: transparent;
+  color: var(--ai-code-text);
+}
+.ai-page .ai-markdown blockquote {
+  border-left-color: var(--ai-border);
+  color: var(--ai-text-secondary);
+}
+.ai-page .ai-markdown th,
+.ai-page .ai-markdown td {
+  border-color: var(--ai-border);
+}
+.ai-page .ai-markdown th {
+  background: var(--ai-fill-secondary);
+  color: var(--ai-text);
+}
 /* === Chat Input === */
 .chat-input {
   width: 100%;
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index 6c53e9fcd..ee87e31d2 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -44,6 +44,7 @@ import { useLang } from '../i18n/LangContext';
 import { useTheme } from '../theme/useTheme';
 import { logout as requestLogout } from '../api/auth';
 import useAuthStore from '../stores/authStore';
+import { clearAiChatHistories } from '../stores/aiChatHistoryStore';
 import {
   filterNavigationEntries,
   isNavigationSearchShortcut,
@@ -86,6 +87,7 @@ const MainLayout = () => {
     } catch {
       message.warning('服务端退出失败,已清除本地登录状态');
     } finally {
+      clearAiChatHistories();
       clearAuth();
       navigate('/login', { replace: true });
     }
diff --git a/web/src/pages/ai/__tests__/AiMessage.test.tsx 
b/web/src/pages/ai/__tests__/AiMessage.test.tsx
index c3142672b..84571161a 100644
--- a/web/src/pages/ai/__tests__/AiMessage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiMessage.test.tsx
@@ -47,5 +47,6 @@ describe('AiMessage', () => {
     expect(screen.getByText('QPS/TPS')).toBeInTheDocument();
     expect(screen.getByRole('table')).toBeInTheDocument();
     expect(screen.getByText('mqadmin clusterList')).toBeInTheDocument();
+    expect(screen.getByText('mqadmin 
clusterList').closest('pre')).toBeInTheDocument();
   });
 });
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 4392bbf7f..863318e53 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -24,8 +24,11 @@ import { LangProvider } from '../../../i18n/LangContext';
 import { chatStream, executeTool, listTools } from '../../../api/ai';
 import { listClusters, type ClusterInfo } from '../../../api/cluster';
 import { getLlmConfig, getLlmModels } from '../../../api/llm';
+import { useAiChatHistoryStore } from '../../../stores/aiChatHistoryStore';
 import AiPage from '../index';
 
+const dataModeMocks = vi.hoisted(() => ({ useMock: false }));
+
 vi.mock('../../../api/ai', () => ({
   AiStreamError: class AiStreamError extends Error {},
   chatStream: vi.fn(),
@@ -42,6 +45,10 @@ vi.mock('../../../api/cluster', () => ({
   listClusters: vi.fn(),
 }));
 
+vi.mock('../../../stores/dataModeStore', () => ({
+  useDataModeStore: (selector: (state: typeof dataModeMocks) => unknown) => 
selector(dataModeMocks),
+}));
+
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -73,6 +80,14 @@ const renderPage = (state?: unknown) =>
 describe('AiPage tool runner', () => {
   beforeEach(() => {
     vi.clearAllMocks();
+    dataModeMocks.useMock = false;
+    sessionStorage.clear();
+    useAiChatHistoryStore.setState({
+      histories: {
+        mock: { conversations: [], activeConversationId: null },
+        real: { conversations: [], activeConversationId: null },
+      },
+    });
     vi.mocked(getLlmConfig).mockResolvedValue({
       provider: 'openai',
       apiBase: 'https://api.openai.com/v1',
@@ -105,6 +120,20 @@ describe('AiPage tool runner', () => {
     ]);
   });
 
+  it('does not load LLM configuration or tools in mock mode', async () => {
+    dataModeMocks.useMock = true;
+    const user = userEvent.setup();
+    renderPage();
+
+    expect(await screen.findByText('Mock 模式已禁用 AI Provider 
调用')).toBeInTheDocument();
+    expect(getLlmConfig).not.toHaveBeenCalled();
+    expect(getLlmModels).not.toHaveBeenCalled();
+
+    await user.click(screen.getByRole('button', { name: '工具' }));
+    expect(listTools).not.toHaveBeenCalled();
+    expect(listClusters).not.toHaveBeenCalled();
+  });
+
   it('uses the mode carried from the home-page draft', async () => {
     vi.mocked(chatStream).mockResolvedValue(undefined);
     renderPage({ prompt: '检查集群状态', mode: 'diagnose' });
@@ -119,6 +148,146 @@ describe('AiPage tool runner', () => {
     });
   });
 
+  it('starts a new conversation when the home-page draft requests it', async 
() => {
+    useAiChatHistoryStore.setState({
+      histories: {
+        mock: { conversations: [], activeConversationId: null },
+        real: {
+          conversations: [
+            {
+              id: 'previous-conversation',
+              messages: [{ id: 'previous', role: 'user', text: 'Previous 
conversation' }],
+              updatedAt: new Date(2026, 7, 13, 9, 45).getTime(),
+            },
+          ],
+          activeConversationId: 'previous-conversation',
+        },
+      },
+    });
+    vi.mocked(chatStream).mockResolvedValue(undefined);
+
+    renderPage({ prompt: 'New conversation', newConversation: true });
+
+    await waitFor(() => {
+      expect(chatStream).toHaveBeenCalledWith(
+        expect.objectContaining({ message: 'New conversation' }),
+        expect.any(Function),
+        expect.any(AbortSignal),
+        expect.any(Function),
+      );
+    });
+    
expect(useAiChatHistoryStore.getState().histories.real.conversations).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          messages: expect.arrayContaining([expect.objectContaining({ text: 
'Previous conversation' })]),
+        }),
+      ]),
+    );
+  });
+
+  it('uses the conversation update time for legacy messages without message 
timestamps', async () => {
+    useAiChatHistoryStore.setState({
+      histories: {
+        mock: { conversations: [], activeConversationId: null },
+        real: {
+          conversations: [
+            {
+              id: 'previous-conversation',
+              messages: [{ id: 'previous', role: 'user', text: 'Previous 
conversation' }],
+              updatedAt: new Date(2026, 7, 13, 9, 45).getTime(),
+            },
+          ],
+          activeConversationId: null,
+        },
+      },
+    });
+
+    renderPage({ conversationId: 'previous-conversation' });
+
+    expect(await screen.findByText('Previous 
conversation')).toBeInTheDocument();
+    expect(screen.getByText('09:45')).toBeInTheDocument();
+    expect(chatStream).not.toHaveBeenCalled();
+  });
+
+  it('switches conversations from the AI-page history drawer without sending a 
request', async () => {
+    const now = Date.now();
+    useAiChatHistoryStore.setState({
+      histories: {
+        mock: { conversations: [], activeConversationId: null },
+        real: {
+          conversations: [
+            {
+              id: 'active',
+              messages: [{ id: 'active-message', role: 'user', text: 'Active 
conversation' }],
+              updatedAt: now,
+            },
+            {
+              id: 'previous',
+              messages: [{ id: 'previous-message', role: 'user', text: 
'Previous conversation' }],
+              updatedAt: now - 5 * 60_000,
+            },
+          ],
+          activeConversationId: 'active',
+        },
+      },
+    });
+    const user = userEvent.setup();
+    renderPage();
+
+    await user.click(await screen.findByRole('button', { name: 'AI 对话历史' }));
+    expect(screen.getByText('刚刚')).toBeInTheDocument();
+    expect(screen.getByText('5 分钟前')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /^Previous conversation5 分钟前$/ 
})).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /^Previous 
conversation/ }));
+
+    expect(
+      await screen.findByText('Previous conversation', { selector: 
'div[style*="max-width"]' }),
+    ).toBeInTheDocument();
+    
expect(useAiChatHistoryStore.getState().histories.real.activeConversationId).toBe('previous');
+    expect(chatStream).not.toHaveBeenCalled();
+  });
+
+  it('stops an in-flight response before switching conversations', async () => 
{
+    useAiChatHistoryStore.setState({
+      histories: {
+        mock: { conversations: [], activeConversationId: null },
+        real: {
+          conversations: [
+            {
+              id: 'previous',
+              messages: [{ id: 'previous-message', role: 'user', text: 
'Previous conversation' }],
+              updatedAt: Date.now() - 60_000,
+            },
+          ],
+          activeConversationId: 'previous',
+        },
+      },
+    });
+    let requestSignal: AbortSignal | undefined;
+    vi.mocked(chatStream).mockImplementation(
+      (_request, _onChunk, signal) =>
+        new Promise<void>((resolve) => {
+          requestSignal = signal;
+          if (signal) {
+            signal.addEventListener('abort', () => resolve());
+          } else {
+            resolve();
+          }
+        }),
+    );
+    const user = userEvent.setup();
+    renderPage({ prompt: 'Start streaming', newConversation: true });
+
+    await waitFor(() => expect(requestSignal).toBeDefined());
+    await user.click(screen.getByRole('button', { name: 'AI 对话历史' }));
+    const historyDrawer = await screen.findByRole('dialog', { name: 'AI 对话历史' 
});
+    await user.click(within(historyDrawer).getByRole('button', { name: 
/^Previous conversation/ }));
+
+    expect(requestSignal?.aborted).toBe(true);
+    
expect(useAiChatHistoryStore.getState().histories.real.activeConversationId).toBe('previous');
+    await waitFor(() => expect(screen.queryByRole('button', { name: '停止' 
})).not.toBeInTheDocument());
+  });
+
   it('loads the catalog, creates a schema template, and renders structured 
output', async () => {
     const user = userEvent.setup();
     vi.mocked(executeTool).mockResolvedValue({
diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/pages/ai/chatDraft.ts
index b206cf321..17ed09034 100644
--- a/web/src/pages/ai/chatDraft.ts
+++ b/web/src/pages/ai/chatDraft.ts
@@ -24,12 +24,19 @@ export interface ChatDraft {
   model?: string;
   mode?: ChatMode;
   enhance?: boolean;
+  newConversation?: boolean;
+  conversationId?: string;
 }
 
 export function getChatDraft(state: unknown): ChatDraft | null {
   if (typeof state !== 'object' || state === null) return null;
   const candidate = state as Record<string, unknown>;
-  if (typeof candidate.prompt !== 'string' || !candidate.prompt.trim()) return 
null;
+  const prompt = typeof candidate.prompt === 'string' ? 
candidate.prompt.trim() : '';
+  const conversationId =
+    typeof candidate.conversationId === 'string' && 
candidate.conversationId.trim()
+      ? candidate.conversationId
+      : undefined;
+  if (!prompt && !conversationId) return null;
   const model = typeof candidate.model === 'string' ? candidate.model.trim() : 
'';
   const mode =
     typeof candidate.mode === 'string' && CHAT_MODES.has(candidate.mode as 
ChatMode)
@@ -37,9 +44,11 @@ export function getChatDraft(state: unknown): ChatDraft | 
null {
       : undefined;
 
   return {
-    prompt: candidate.prompt.trim(),
+    prompt,
     ...(model ? { model } : {}),
     ...(mode ? { mode } : {}),
     ...(candidate.enhance === true ? { enhance: true } : {}),
+    ...(candidate.newConversation === true ? { newConversation: true } : {}),
+    ...(conversationId ? { conversationId } : {}),
   };
 }
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 8f02ccbd3..4ec8da584 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useState, useRef, useEffect, useCallback } from 'react';
+import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties 
} from 'react';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
 import { useLocation, useNavigate } from 'react-router-dom';
@@ -31,6 +31,8 @@ import {
   Descriptions,
   Flex,
   Divider,
+  Drawer,
+  Empty,
   Select,
   Alert,
   Input,
@@ -39,13 +41,27 @@ import {
   theme,
   message,
 } from 'antd';
-import { ArrowUp, Sparkle, SlidersHorizontal, CaretDown } from 
'@phosphor-icons/react';
+import {
+  ArrowUp,
+  CaretDown,
+  ClockCounterClockwise,
+  SlidersHorizontal,
+  Sparkle,
+} from '@phosphor-icons/react';
 import type { ColumnsType } from 'antd/es/table';
 import { useLang } from '../../i18n/LangContext';
 import { AiStreamError, chatStream, executeTool, listTools, type McpTool } 
from '../../api/ai';
 import { listClusters } from '../../api/cluster';
 import { getLlmConfig, getLlmModels, type LlmConfig } from '../../api/llm';
+import { formatRelativeTime, formatTimeOfDay } from '../../utils/format';
+import { useDataModeStore } from '../../stores/dataModeStore';
 import { useEngineStore } from '../../stores/engineStore';
+import {
+  getRecentAiChatConversations,
+  flushAiChatHistoryPersistence,
+  type AiChatDataMode,
+  useAiChatHistoryStore,
+} from '../../stores/aiChatHistoryStore';
 import { getChatDraft, type ChatMode } from './chatDraft';
 
 const { Text } = Typography;
@@ -79,6 +95,7 @@ interface DescriptionItem {
 interface Message {
   id: string;
   role: 'user' | 'ai';
+  createdAt?: number;
   text?: string;
   toolCall?: ToolCallTag;
   tableData?: TopicRow[];
@@ -93,8 +110,6 @@ interface Message {
 
 /* ─── Mock Data ─── */
 
-const initialMessages: Message[] = [];
-
 /* ─── Quick Actions ─── */
 
 const quickActions = [
@@ -108,6 +123,9 @@ const quickActions = [
 
 const GLOBAL_TOOL_SCOPE = '__global__';
 
+const newConversationId = (): string =>
+  `conversation-${typeof crypto?.randomUUID === 'function' ? 
crypto.randomUUID() : `${Date.now()}-${Math.random()}`}`;
+
 const isRecord = (value: unknown): value is Record<string, unknown> =>
   typeof value === 'object' && value !== null && !Array.isArray(value);
 
@@ -149,32 +167,49 @@ const formatToolResult = (result: unknown): string =>
 
 /* ─── Sub-components ─── */
 
-const UserBubble = ({ text }: { text: string }) => (
-  <Flex justify="flex-end" style={{ marginBottom: 16 }}>
-    <div
-      style={{
-        maxWidth: '70%',
-        padding: '10px 16px',
-        background: '#e6f4ff',
-        borderRadius: 16,
-        borderTopRightRadius: 4,
-        lineHeight: 1.6,
-        fontSize: 14,
-      }}
-    >
-      {text}
-    </div>
-  </Flex>
-);
+const UserBubble = ({ text, createdAt }: Pick<Message, 'text' | 'createdAt'>) 
=> {
+  const { token } = theme.useToken();
+
+  return (
+    <Flex justify="flex-end" style={{ marginBottom: 16 }}>
+      <div
+        className="ai-user-bubble"
+        style={{
+          maxWidth: '70%',
+          padding: '10px 16px',
+          background: token.colorPrimaryBg,
+          color: token.colorText,
+          border: `1px solid ${token.colorPrimaryBorder}`,
+          borderRadius: 16,
+          borderTopRightRadius: 4,
+          lineHeight: 1.6,
+          fontSize: 14,
+        }}
+      >
+        {text}
+        {createdAt && (
+          <div
+            style={{ marginTop: 4, color: token.colorTextTertiary, fontSize: 
14, textAlign: 'right' }}
+          >
+            {formatTimeOfDay(createdAt)}
+          </div>
+        )}
+      </div>
+    </Flex>
+  );
+};
+
+export const AiMessage = ({ msg }: { msg: Message }) => {
+  const { token } = theme.useToken();
 
-export const AiMessage = ({ msg }: { msg: Message }) => (
+  return (
   <Flex gap={12} align="flex-start" style={{ marginBottom: 16 }}>
     <div
       style={{
         width: 36,
         height: 36,
         borderRadius: '50%',
-        background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)',
+        background: `linear-gradient(135deg, ${token.colorPrimary} 0%, 
${token.colorPrimaryHover} 100%)`,
         flexShrink: 0,
         display: 'flex',
         alignItems: 'center',
@@ -202,7 +237,9 @@ export const AiMessage = ({ msg }: { msg: Message }) => (
       size="small"
       style={{
         maxWidth: '75%',
-        boxShadow: '0 1px 4px rgba(0, 0, 0, 0.06)',
+        background: token.colorBgElevated,
+        borderColor: token.colorBorderSecondary,
+        boxShadow: `0 1px 4px ${token.colorTextQuaternary}`,
         borderRadius: 12,
         borderTopLeftRadius: 4,
       }}
@@ -216,8 +253,8 @@ export const AiMessage = ({ msg }: { msg: Message }) => (
             marginBottom: 12,
             borderRadius: 6,
             fontSize: 14,
-            background: '#f9f0ff',
-            borderColor: '#d3adf7',
+            background: token.colorPrimaryBg,
+            borderColor: token.colorPrimaryBorder,
           }}
         >
           {msg.toolCall.label}
@@ -283,7 +320,7 @@ export const AiMessage = ({ msg }: { msg: Message }) => (
           <summary
             style={{
               cursor: 'pointer',
-              color: '#722ed1',
+              color: token.colorPrimary,
               fontSize: 14,
               fontWeight: 500,
               userSelect: 'none',
@@ -295,12 +332,12 @@ export const AiMessage = ({ msg }: { msg: Message }) => (
             style={{
               marginTop: 8,
               padding: '8px 12px',
-              background: '#f9f0ff',
-              border: '1px solid #efdbff',
+              background: token.colorFillSecondary,
+              border: `1px solid ${token.colorBorderSecondary}`,
               borderRadius: 8,
               fontSize: 14,
               lineHeight: 1.7,
-              color: '#595959',
+              color: token.colorTextSecondary,
               whiteSpace: 'pre-wrap',
             }}
           >
@@ -318,7 +355,7 @@ export const AiMessage = ({ msg }: { msg: Message }) => (
               width: 6,
               height: 6,
               borderRadius: '50%',
-              background: '#722ed1',
+              background: token.colorPrimary,
               animation: 'dotPulse 1.4s infinite ease-in-out',
             }}
           />
@@ -328,7 +365,7 @@ export const AiMessage = ({ msg }: { msg: Message }) => (
               width: 6,
               height: 6,
               borderRadius: '50%',
-              background: '#722ed1',
+              background: token.colorPrimary,
               animation: 'dotPulse 1.4s infinite ease-in-out 0.2s',
             }}
           />
@@ -338,7 +375,7 @@ export const AiMessage = ({ msg }: { msg: Message }) => (
               width: 6,
               height: 6,
               borderRadius: '50%',
-              background: '#722ed1',
+              background: token.colorPrimary,
               animation: 'dotPulse 1.4s infinite ease-in-out 0.4s',
             }}
           />
@@ -368,20 +405,39 @@ export const AiMessage = ({ msg }: { msg: Message }) => (
           </Flex>
         </>
       )}
+
+      {msg.createdAt && (
+        <div style={{ marginTop: 8, color: token.colorTextTertiary, fontSize: 
14 }}>
+          {formatTimeOfDay(msg.createdAt)}
+        </div>
+      )}
     </Card>
   </Flex>
-);
+  );
+};
 
 /* ═══════════════════════════════════════════
    AiPage
    ═══════════════════════════════════════════ */
 
 const AiPage = () => {
-  const { t } = useLang();
+  const { t, lang } = useLang();
   const location = useLocation();
   const navigate = useNavigate();
+  const useMock = useDataModeStore((state) => state.useMock);
+  const chatMode: AiChatDataMode = useMock ? 'mock' : 'real';
   const { token } = theme.useToken();
-  const [messages, setMessages] = useState<Message[]>(initialMessages);
+  const history = useAiChatHistoryStore((state) => state.histories[chatMode]);
+  const activeConversation = useMemo(
+    () => history.conversations.find((item) => item.id === 
history.activeConversationId),
+    [history],
+  );
+  const messages = useMemo(() => activeConversation?.messages ?? [], 
[activeConversation]);
+  const legacyMessageTimestamp = activeConversation?.updatedAt || undefined;
+  const updateMessages = useAiChatHistoryStore((state) => state.setMessages);
+  const startConversation = useAiChatHistoryStore((state) => 
state.startConversation);
+  const selectConversation = useAiChatHistoryStore((state) => 
state.selectConversation);
+  const [historyOpen, setHistoryOpen] = useState(false);
   const [inputValue, setInputValue] = useState('');
   const [loading, setLoading] = useState(false);
   const [settingsHintDismissed, setSettingsHintDismissed] = useState(false);
@@ -403,7 +459,9 @@ const AiPage = () => {
   const chatEndRef = useRef<HTMLDivElement>(null);
   const textareaRef = useRef<HTMLTextAreaElement>(null);
   const abortControllerRef = useRef<AbortController | null>(null);
-  const conversationIdRef = useRef<string | null>(null);
+  const streamRequestIdRef = useRef(0);
+  const previousChatModeRef = useRef(chatMode);
+  const conversationIdRef = useRef<string | 
null>(history.activeConversationId);
   const toolLoadRequestRef = useRef(0);
   const consumedDraftRef = useRef(false);
   const pendingAutoSendRef = useRef<{
@@ -421,7 +479,25 @@ const AiPage = () => {
     scrollToBottom();
   }, [messages, scrollToBottom]);
 
+  useEffect(() => {
+    if (previousChatModeRef.current !== chatMode) {
+      abortControllerRef.current?.abort();
+      abortControllerRef.current = null;
+      streamRequestIdRef.current += 1;
+      setLoading(false);
+      previousChatModeRef.current = chatMode;
+    }
+    conversationIdRef.current = 
useAiChatHistoryStore.getState().histories[chatMode].activeConversationId;
+  }, [chatMode, history.activeConversationId]);
+
   const loadLlmRuntime = useCallback(async () => {
+    if (useMock) {
+      setLlmConfig(null);
+      setModelOptions([]);
+      setSelectedModel('');
+      setModelsLoading(false);
+      return;
+    }
     setModelsLoading(true);
     try {
       const config = await getLlmConfig();
@@ -442,11 +518,11 @@ const AiPage = () => {
         setModelOptions([{ value: config.model, label: config.model }]);
       }
     } catch (error) {
-      message.error(error instanceof Error ? error.message : 'AI 配置加载失败');
+      message.error(error instanceof Error ? error.message : 
t('ai.runtimeLoadFailed'));
     } finally {
       setModelsLoading(false);
     }
-  }, []);
+  }, [t, useMock]);
 
   useEffect(() => {
     void Promise.resolve().then(loadLlmRuntime);
@@ -458,7 +534,15 @@ const AiPage = () => {
     consumedDraftRef.current = true;
 
     void Promise.resolve().then(() => {
-      setInputValue(draft.prompt);
+      if (draft.newConversation) {
+        const nextConversationId = newConversationId();
+        startConversation(chatMode, nextConversationId);
+        conversationIdRef.current = nextConversationId;
+      } else if (draft.conversationId) {
+        selectConversation(chatMode, draft.conversationId);
+        conversationIdRef.current = draft.conversationId;
+      }
+      if (draft.prompt) setInputValue(draft.prompt);
       const draftModel = draft.model;
       if (draftModel) {
         setSelectedModel(draftModel);
@@ -468,15 +552,17 @@ const AiPage = () => {
             : [{ value: draftModel, label: draftModel }, ...options],
         );
       }
-      pendingAutoSendRef.current = {
-        prompt: draft.prompt,
-        model: draft.model,
-        mode: draft.mode,
-        enhance: draft.enhance,
-      };
+      if (draft.prompt) {
+        pendingAutoSendRef.current = {
+          prompt: draft.prompt,
+          model: draft.model,
+          mode: draft.mode,
+          enhance: draft.enhance,
+        };
+      }
       navigate('/ai', { replace: true, state: null });
     });
-  }, [location.state, navigate]);
+  }, [chatMode, location.state, navigate, selectConversation, 
startConversation]);
 
   /* ─── Auto-resize textarea ─── */
   useEffect(() => {
@@ -507,25 +593,30 @@ const AiPage = () => {
       const model = modelOverride ?? selectedModel;
       if (!text || loading) return;
       if (!llmReady) {
-        message.warning('请先配置并启用 LLM Provider');
+        message.warning(t('ai.providerRequired'));
         return;
       }
 
       if (!conversationIdRef.current) {
-        conversationIdRef.current = `conversation-${Date.now()}`;
+        conversationIdRef.current = newConversationId();
+        startConversation(chatMode, conversationIdRef.current);
       }
 
+      const conversationId = conversationIdRef.current;
+      const requestId = ++streamRequestIdRef.current;
+      const createdAt = Date.now();
       const userMsg: Message = {
-        id: `user-${Date.now()}`,
+        id: `user-${createdAt}`,
         role: 'user',
         text,
+        createdAt,
       };
 
       const responseId = `ai-${Date.now()}`;
-      setMessages((prev) => [
+      updateMessages(chatMode, conversationId, (prev) => [
         ...prev,
         userMsg,
-        { id: responseId, role: 'ai', summary: '', pending: true },
+        { id: responseId, role: 'ai', summary: '', pending: true, createdAt },
       ]);
       setInputValue('');
       if (textareaRef.current) {
@@ -543,10 +634,11 @@ const AiPage = () => {
             model,
             engine: useEngineStore.getState().engine,
             enhance,
-            conversationId: conversationIdRef.current,
+            conversationId,
           },
           (chunk) => {
-            setMessages((prev) =>
+            if (streamRequestIdRef.current !== requestId || 
controller.signal.aborted) return;
+            updateMessages(chatMode, conversationId, (prev) =>
               prev.map((item) =>
                 item.id === responseId
                   ? { ...item, summary: `${item.summary ?? ''}${chunk}` }
@@ -556,7 +648,8 @@ const AiPage = () => {
           },
           controller.signal,
           (enhanceDelta) => {
-            setMessages((prev) =>
+            if (streamRequestIdRef.current !== requestId || 
controller.signal.aborted) return;
+            updateMessages(chatMode, conversationId, (prev) =>
               prev.map((item) =>
                 item.id === responseId
                   ? { ...item, thinking: `${item.thinking ?? 
''}${enhanceDelta}` }
@@ -567,29 +660,30 @@ const AiPage = () => {
         );
       } catch (error) {
         if (controller.signal.aborted) {
-          setMessages((prev) =>
+          updateMessages(chatMode, conversationId, (prev) =>
             prev.map((item) =>
-              item.id === responseId && !item.summary ? { ...item, summary: 
'回答已停止。' } : item,
+              item.id === responseId && !item.summary ? { ...item, summary: 
t('ai.responseStopped') } : item,
             ),
           );
         } else {
-          const errorMessage = error instanceof Error ? error.message : 'AI 
请求失败';
+          const errorMessage = error instanceof Error ? error.message : 
t('ai.requestFailed');
           const errorHint = error instanceof AiStreamError && error.hint ? 
error.hint : '';
           const summary = errorHint ? `${errorMessage}\n\n> ${errorHint}` : 
errorMessage;
-          setMessages((prev) =>
+          updateMessages(chatMode, conversationId, (prev) =>
             prev.map((item) => (item.id === responseId ? { ...item, summary } 
: item)),
           );
           message.error(errorMessage);
         }
       } finally {
         if (abortControllerRef.current === controller) 
abortControllerRef.current = null;
-        setMessages((prev) =>
+        updateMessages(chatMode, conversationId, (prev) =>
           prev.map((item) => (item.id === responseId ? { ...item, pending: 
false } : item)),
         );
-        setLoading(false);
+        flushAiChatHistoryPersistence();
+        if (streamRequestIdRef.current === requestId) setLoading(false);
       }
     },
-    [inputValue, llmReady, loading, selectedModel],
+    [chatMode, inputValue, llmReady, loading, selectedModel, 
startConversation, t, updateMessages],
   );
 
   /* ─── Auto-send the draft from the home page as soon as runtime is ready 
─── */
@@ -619,6 +713,18 @@ const AiPage = () => {
     textareaRef.current?.focus();
   }, []);
 
+  const recentConversations = 
getRecentAiChatConversations(history.conversations);
+
+  const handleConversationSelect = (conversationId: string) => {
+    abortControllerRef.current?.abort();
+    abortControllerRef.current = null;
+    streamRequestIdRef.current += 1;
+    setLoading(false);
+    selectConversation(chatMode, conversationId);
+    conversationIdRef.current = conversationId;
+    setHistoryOpen(false);
+  };
+
   const selectTool = useCallback(
     (name: string, availableTools: McpTool[] = tools, clusterId: string = 
selectedClusterId) => {
       const tool = availableTools.find((item) => item.name === name);
@@ -626,7 +732,7 @@ const AiPage = () => {
       setToolInput(tool ? buildToolInputTemplate(tool, clusterId) : '{}');
       setToolResult(undefined);
     },
-    [selectedClusterId, tools],
+    [selectedClusterId, setSelectedToolName, setToolInput, setToolResult, 
tools],
   );
 
   const loadTools = useCallback(
@@ -644,16 +750,20 @@ const AiPage = () => {
       } catch {
         if (requestId === toolLoadRequestRef.current) {
           setTools([]);
-          message.error('AI 工具目录加载失败');
+          message.error(t('ai.toolCatalogLoadFailed'));
         }
       } finally {
         if (requestId === toolLoadRequestRef.current) setToolsLoading(false);
       }
     },
-    [selectTool],
+    [selectTool, t],
   );
 
   const handleOpenTools = useCallback(async () => {
+    if (useMock) {
+      message.info(t('ai.mockToolsUnavailable'));
+      return;
+    }
     setToolModalOpen(true);
     setToolResult(undefined);
     if (tools.length > 0 || toolsLoading || clustersLoading) return;
@@ -667,13 +777,25 @@ const AiPage = () => {
       clusterId = options[0]?.value ?? '';
       setSelectedClusterId(clusterId);
     } catch {
-      message.warning('集群列表加载失败,已显示全局工具');
+      message.warning(t('ai.clusterListLoadFailed'));
     } finally {
       setClustersLoading(false);
     }
 
     await loadTools(clusterId);
-  }, [clustersLoading, loadTools, tools.length, toolsLoading]);
+  }, [
+    clustersLoading,
+    loadTools,
+    setClusterOptions,
+    setClustersLoading,
+    setSelectedClusterId,
+    setToolModalOpen,
+    setToolResult,
+    t,
+    tools.length,
+    toolsLoading,
+    useMock,
+  ]);
 
   const handleClusterChange = useCallback(
     async (scope: string) => {
@@ -714,23 +836,28 @@ const AiPage = () => {
   const selectedTool = tools.find((tool) => tool.name === selectedToolName);
 
   return (
-    <Flex vertical style={{ height: '100%', minHeight: 0, padding: 24, 
overflow: 'hidden' }}>
-      {!settingsHintDismissed && (
-        <Alert
-          type="info"
-          showIcon
-          closable
-          banner
-          onClose={() => setSettingsHintDismissed(true)}
-          style={{ marginBottom: 12, borderRadius: 8 }}
-          message={
-            <span>
-              模型服务与执行引擎可在 <a onClick={() => navigate('/settings')}>设置 → AI 
助手</a>{' '}
-              中配置
-            </span>
-          }
-        />
-      )}
+    <Flex
+      vertical
+      className="ai-page"
+      style={{
+        height: '100%',
+        minHeight: 0,
+        padding: 24,
+        overflow: 'hidden',
+        '--ai-surface': token.colorBgContainer,
+        '--ai-surface-elevated': token.colorBgElevated,
+        '--ai-border': token.colorBorderSecondary,
+        '--ai-text': token.colorText,
+        '--ai-text-secondary': token.colorTextSecondary,
+        '--ai-text-tertiary': token.colorTextTertiary,
+        '--ai-primary': token.colorPrimary,
+        '--ai-primary-bg': token.colorPrimaryBg,
+        '--ai-primary-hover': token.colorPrimaryHover,
+        '--ai-fill-secondary': token.colorFillSecondary,
+        '--ai-code-bg': token.colorBgSpotlight,
+        '--ai-code-text': token.colorTextLightSolid,
+      } as CSSProperties}
+    >
       {/* Chat Area */}
       <div
         className="w-full scrollbar-hide"
@@ -744,9 +871,16 @@ const AiPage = () => {
       >
         {messages.map((msg) =>
           msg.role === 'user' ? (
-            <UserBubble key={msg.id} text={msg.text!} />
+            <UserBubble
+              key={msg.id}
+              text={msg.text!}
+              createdAt={msg.createdAt ?? legacyMessageTimestamp}
+            />
           ) : (
-            <AiMessage key={msg.id} msg={msg} />
+            <AiMessage
+              key={msg.id}
+              msg={msg.createdAt || !legacyMessageTimestamp ? msg : { ...msg, 
createdAt: legacyMessageTimestamp }}
+            />
           ),
         )}
         <div ref={chatEndRef} />
@@ -784,17 +918,42 @@ const AiPage = () => {
             showIcon
             style={{ marginBottom: 12 }}
             message="AI 助手未启用"
-            description="请先在 设置 → AI 助手 中配置并启用模型服务,启用前不会发送请求或返回 stub 回复。"
+            description={t('ai.providerNotReadyDescription')}
             action={
-              <Button size="small" onClick={() => navigate('/settings')}>
+              <Button size="small" onClick={() => 
navigate('/settings?tab=ai')}>
                 去配置
               </Button>
             }
           />
         )}
+        {!settingsHintDismissed && (
+          <Alert
+            type="info"
+            showIcon
+            closable
+            banner
+            onClose={() => setSettingsHintDismissed(true)}
+            style={{ marginBottom: 12, borderRadius: 8 }}
+            message={
+              <span>
+                模型服务与执行引擎可在 <a onClick={() => navigate('/settings')}>设置 → AI 
助手</a>{' '}
+                中配置
+              </span>
+            }
+          />
+        )}
+        {useMock && (
+          <Alert
+            type="info"
+            showIcon
+            style={{ marginBottom: 12 }}
+            message={t('ai.mockProviderDisabled')}
+            description={t('ai.mockProviderDisabledDescription')}
+          />
+        )}
 
         {/* Main Input Box */}
-        <div className="relative overflow-visible border-[1.5px] 
backdrop-blur-xl border-white rounded-2xl bg-white/80 
shadow-[0_20px_60px_-20px_rgba(80,90,180,0.18)]">
+        <div className="ai-chat-panel relative overflow-visible border-[1.5px] 
backdrop-blur-xl rounded-2xl">
           {/* Model Selector */}
           <div className="flex items-center justify-between gap-3 px-3.5 pt-4">
             <div className="flex flex-1 min-w-0 items-center gap-2">
@@ -818,6 +977,15 @@ const AiPage = () => {
                 </Tag>
               )}
             </div>
+            <button
+              type="button"
+              className="p-1 rounded-md text-gray-400 hover:text-gray-600 
hover:bg-gray-50 transition-colors"
+              aria-label={t('ai.history.title')}
+              title={t('ai.history.title')}
+              onClick={() => setHistoryOpen(true)}
+            >
+              <ClockCounterClockwise size={20} />
+            </button>
           </div>
 
           {/* Textarea */}
@@ -842,7 +1010,7 @@ const AiPage = () => {
           </div>
 
           {/* Bottom Toolbar */}
-          <div className="flex justify-between text-sm items-center px-3.5 
py-3 border-t border-gray-100/80">
+          <div className="ai-chat-toolbar flex justify-between text-sm 
items-center px-3.5 py-3 border-t">
             <div className="flex flex-1 gap-1 items-center min-w-0">
               <div className="flex items-center gap-2 w-full">
                 <div className="flex-1 min-w-0">
@@ -867,7 +1035,7 @@ const AiPage = () => {
                 </div>
                 <div className="shrink-0 flex items-center gap-1">
                   <button
-                    className="flex items-center justify-center w-9 h-9 
rounded-full bg-gradient-to-r from-purple-500 to-violet-600 text-white 
shadow-lg hover:shadow-xl transition-all hover:scale-105"
+                    className="ai-send-button flex items-center justify-center 
w-9 h-9 rounded-full text-white shadow-lg hover:shadow-xl transition-all 
hover:scale-105"
                     onClick={() => void handleSend(undefined, undefined, 
enhance)}
                     disabled={loading || !inputValue.trim() || !llmReady}
                     style={{
@@ -890,6 +1058,50 @@ const AiPage = () => {
         </div>
       </div>
 
+      <Drawer
+        title={t('ai.history.title')}
+        placement="right"
+        width={360}
+        open={historyOpen}
+        onClose={() => setHistoryOpen(false)}
+      >
+        {recentConversations.length === 0 ? (
+          <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} 
description={t('ai.history.empty')} />
+        ) : (
+          <div className="flex flex-col gap-2">
+            {recentConversations.map((conversation) => (
+              <button
+                key={conversation.id}
+                type="button"
+                onClick={() => handleConversationSelect(conversation.id)}
+                className={`w-full rounded-md border px-3 py-2 text-left 
text-sm transition-colors ${
+                  conversation.id === history.activeConversationId
+                    ? 'border-blue-400 bg-blue-50 text-blue-700'
+                    : 'border-gray-200 bg-white text-gray-700 
hover:border-blue-300 hover:bg-blue-50'
+                }`}
+              >
+                <span style={{ display: 'flex', alignItems: 'center', gap: 12, 
minWidth: 0 }}>
+                  <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', 
textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+                    {conversation.prompt}
+                  </span>
+                  <span
+                    style={{
+                      flexShrink: 0,
+                      color: token.colorTextSecondary,
+                      fontSize: 14,
+                      fontWeight: 500,
+                      lineHeight: 1.5,
+                    }}
+                  >
+                    {formatRelativeTime(conversation.updatedAt, lang, t)}
+                  </span>
+                </span>
+              </button>
+            ))}
+          </div>
+        )}
+      </Drawer>
+
       <Modal
         title="AI 工具"
         open={toolModalOpen}
diff --git a/web/src/pages/settings/AiAssistantTab.tsx 
b/web/src/pages/settings/AiAssistantTab.tsx
index d2c8e19e0..f6888c979 100644
--- a/web/src/pages/settings/AiAssistantTab.tsx
+++ b/web/src/pages/settings/AiAssistantTab.tsx
@@ -258,6 +258,13 @@ export const AiAssistantTab = () => {
           setApiKeyConfigured(true);
           form.setFieldValue('apiKey', undefined);
         }
+        try {
+          const models = await getLlmModels();
+          const remoteModels = models.data?.map((model) => model.id || 
'').filter(Boolean) ?? [];
+          setModelOptions(buildModelOptions(payload.provider, remoteModels, 
payload.model));
+        } catch {
+          message.warning(t('ai.modelsRefreshFailedAfterSave'));
+        }
       } else {
         message.error(result.errMsg || '保存失败');
       }
diff --git a/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx 
b/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
index 79ce408cf..005f094e1 100644
--- a/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
+++ b/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
@@ -124,6 +124,27 @@ describe('AiAssistantTab', () => {
     
expect(llmApiMocks.saveLlmConfig.mock.calls[0][0].awsRegion).toBeUndefined();
   });
 
+  it('refreshes the remote model list after saving an API key', async () => {
+    const user = userEvent.setup();
+    llmApiMocks.getLlmModels
+      .mockResolvedValueOnce({ status: 0, data: [{ id: 'qwen3.8-max' }] })
+      .mockResolvedValueOnce({
+        status: 0,
+        data: [{ id: 'qwen3.8-max' }, { id: 'qwen-plus-latest' }],
+      });
+    renderPage();
+
+    await screen.findByText('密钥已配置');
+    await user.type(screen.getByLabelText('API Key'), 'sk-new-key');
+    await user.click(screen.getByRole('button', { name: /保\s*存/ }));
+
+    await waitFor(() => 
expect(llmApiMocks.getLlmModels).toHaveBeenCalledTimes(2));
+    await user.click(screen.getAllByRole('combobox')[2]);
+    expect(
+      await screen.findByText('qwen-plus-latest', { selector: 
'.ant-select-item-option-content' }),
+    ).toBeInTheDocument();
+  });
+
   it('ignores a connection result after the tested configuration changes', 
async () => {
     let resolveTest!: (result: { status: number; msg: string }) => void;
     llmApiMocks.testLlmConnection.mockImplementationOnce(
diff --git a/web/src/stores/aiChatHistoryStore.test.ts 
b/web/src/stores/aiChatHistoryStore.test.ts
new file mode 100644
index 000000000..5c1ee3905
--- /dev/null
+++ b/web/src/stores/aiChatHistoryStore.test.ts
@@ -0,0 +1,134 @@
+/*
+ * 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.
+ * 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 { afterEach, describe, expect, it, vi } from 'vitest';
+
+const STORAGE_KEY = 'rocketmq-studio-ai-chat-history';
+
+async function loadStore(persisted?: object) {
+  vi.resetModules();
+  if (persisted) sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ state: 
persisted, version: 0 }));
+  return (await import('./aiChatHistoryStore')).useAiChatHistoryStore;
+}
+
+describe('aiChatHistoryStore', () => {
+  afterEach(() => {
+    vi.useRealTimers();
+    vi.resetModules();
+    sessionStorage.clear();
+  });
+
+  it('keeps multiple conversations independently for each data mode', async () 
=> {
+    const store = await loadStore();
+    store.getState().startConversation('real', 'real-1');
+    store.getState().setMessages('real', 'real-1', [{ id: 'r1', role: 'user', 
text: 'First' }]);
+    store.getState().startConversation('real', 'real-2');
+    store.getState().setMessages('real', 'real-2', [{ id: 'r2', role: 'user', 
text: 'Second' }]);
+    store.getState().startConversation('mock', 'mock-1');
+    store.getState().setMessages('mock', 'mock-1', [{ id: 'm1', role: 'user', 
text: 'Mock' }]);
+
+    const real = store.getState().histories.real;
+    expect(real.conversations.map((conversation) => 
conversation.id)).toEqual(['real-2', 'real-1']);
+    expect(real.conversations[1].messages[0]?.text).toBe('First');
+    expect(store.getState().histories.mock.conversations).toHaveLength(1);
+  });
+
+  it('migrates the previous current conversation to the Real conversation 
list', async () => {
+    const store = await loadStore({
+      conversationId: 'conversation-2',
+      messages: [{ id: 'message-2', role: 'ai', summary: '', pending: true }],
+    });
+
+    expect(store.getState().histories.mock).toEqual({ conversations: [], 
activeConversationId: null });
+    
expect(store.getState().histories.real.activeConversationId).toBe('conversation-2');
+    expect(store.getState().histories.real.conversations[0]).toMatchObject({
+      id: 'conversation-2',
+      messages: [{ id: 'message-2', role: 'ai', summary: '', pending: false }],
+    });
+  });
+
+  it('selects a previous conversation without deleting newer conversations', 
async () => {
+    const store = await loadStore();
+    store.getState().startConversation('real', 'first');
+    store.getState().startConversation('real', 'second');
+
+    store.getState().selectConversation('real', 'first');
+
+    expect(store.getState().histories.real.activeConversationId).toBe('first');
+    expect(store.getState().histories.real.conversations).toHaveLength(2);
+  });
+
+  it('bounds persisted conversations and messages', async () => {
+    const store = await loadStore();
+    for (let index = 0; index < 21; index += 1) {
+      store.getState().startConversation('real', `conversation-${index}`);
+    }
+    const messages = Array.from({ length: 101 }, (_, index) => ({
+      id: `message-${index}`,
+      role: 'user' as const,
+      text: `Message ${index}`,
+    }));
+    store.getState().setMessages('real', 'conversation-20', messages);
+
+    const conversations = store.getState().histories.real.conversations;
+    expect(conversations).toHaveLength(20);
+    expect(conversations[0]?.id).toBe('conversation-20');
+    expect(conversations[0]?.messages).toHaveLength(100);
+    expect(conversations[0]?.messages[0]?.id).toBe('message-1');
+  });
+
+  it('rejects malformed persisted conversation data instead of failing 
hydration', async () => {
+    const store = await loadStore({
+      histories: { real: { conversations: { invalid: true }, 
activeConversationId: 'missing' } },
+    });
+
+    expect(store.getState().histories.real).toEqual({ conversations: [], 
activeConversationId: null });
+  });
+
+  it('bounds a persisted message field before it can exhaust session storage', 
async () => {
+    const { MAX_AI_CHAT_MESSAGE_FIELD_LENGTH } = await 
import('./aiChatHistoryStore');
+    const store = await loadStore();
+    store.getState().setMessages('real', 'conversation-1', [{
+      id: 'answer',
+      role: 'ai',
+      summary: 'x'.repeat(MAX_AI_CHAT_MESSAGE_FIELD_LENGTH + 100),
+    }]);
+
+    
expect(store.getState().histories.real.conversations[0]?.messages[0]?.summary).toHaveLength(
+      MAX_AI_CHAT_MESSAGE_FIELD_LENGTH + '\n\n[Truncated]'.length,
+    );
+  });
+
+  it('clears pending throttled persistence when histories are cleared', async 
() => {
+    vi.useFakeTimers();
+    const store = await loadStore();
+    const { clearAiChatHistories, flushAiChatHistoryPersistence } = await 
import('./aiChatHistoryStore');
+    store.getState().setMessages('real', 'conversation-1', [{ id: 'message', 
role: 'user', text: 'secret' }]);
+    clearAiChatHistories();
+    flushAiChatHistoryPersistence();
+
+    
expect(sessionStorage.getItem(STORAGE_KEY)).toContain('"conversations":[]');
+  });
+
+  it('derives recent conversations from the first user message', async () => {
+    const { getRecentAiChatConversations } = await 
import('./aiChatHistoryStore');
+    const recent = getRecentAiChatConversations([
+      { id: 'empty', messages: [], updatedAt: 3 },
+      { id: 'prompt', messages: [{ id: 'p', role: 'user', text: 'Inspect lag' 
}], updatedAt: 2 },
+    ]);
+
+    expect(recent).toEqual([expect.objectContaining({ id: 'prompt', prompt: 
'Inspect lag' })]);
+  });
+});
diff --git a/web/src/stores/aiChatHistoryStore.ts 
b/web/src/stores/aiChatHistoryStore.ts
new file mode 100644
index 000000000..d8c7b7f37
--- /dev/null
+++ b/web/src/stores/aiChatHistoryStore.ts
@@ -0,0 +1,273 @@
+/*
+ * 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.
+ * 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 { create } from 'zustand';
+import { createJSONStorage, persist, type StateStorage } from 
'zustand/middleware';
+
+export interface AiChatMessage {
+  id: string;
+  role: 'user' | 'ai';
+  createdAt?: number;
+  text?: string;
+  summary?: string;
+  thinking?: string;
+  pending?: boolean;
+}
+
+export type AiChatDataMode = 'mock' | 'real';
+
+export interface AiChatConversation {
+  id: string;
+  messages: AiChatMessage[];
+  updatedAt: number;
+}
+
+export interface AiChatHistory {
+  conversations: AiChatConversation[];
+  activeConversationId: string | null;
+}
+
+export interface RecentAiChatConversation extends AiChatConversation {
+  prompt: string;
+}
+
+export const MAX_AI_CHAT_CONVERSATIONS = 20;
+export const MAX_AI_CHAT_MESSAGES = 100;
+export const MAX_AI_CHAT_HISTORY_BYTES = 512 * 1024;
+export const MAX_AI_CHAT_MESSAGE_FIELD_LENGTH = 16 * 1024;
+
+const AI_CHAT_HISTORY_STORAGE_KEY = 'rocketmq-studio-ai-chat-history';
+const AI_CHAT_HISTORY_PERSIST_INTERVAL_MS = 250;
+
+interface AiChatHistoryState {
+  histories: Record<AiChatDataMode, AiChatHistory>;
+  startConversation: (mode: AiChatDataMode, conversationId: string) => void;
+  selectConversation: (mode: AiChatDataMode, conversationId: string) => void;
+  setMessages: (
+    mode: AiChatDataMode,
+    conversationId: string,
+    messages: AiChatMessage[] | ((messages: AiChatMessage[]) => 
AiChatMessage[]),
+  ) => void;
+  clearHistories: () => void;
+}
+
+const emptyHistory = (): AiChatHistory => ({ conversations: [], 
activeConversationId: null });
+
+const isRecord = (value: unknown): value is Record<string, unknown> =>
+  typeof value === 'object' && value !== null;
+
+const truncateMessageField = (value: unknown): string | undefined => {
+  if (typeof value !== 'string') return undefined;
+  return value.length <= MAX_AI_CHAT_MESSAGE_FIELD_LENGTH
+    ? value
+    : `${value.slice(0, MAX_AI_CHAT_MESSAGE_FIELD_LENGTH)}\n\n[Truncated]`;
+};
+
+const boundMessageFields = (messages: AiChatMessage[]): AiChatMessage[] =>
+  messages.slice(-MAX_AI_CHAT_MESSAGES).map((message) => ({
+    ...message,
+    text: truncateMessageField(message.text),
+    summary: truncateMessageField(message.summary),
+    thinking: truncateMessageField(message.thinking),
+  }));
+
+const restoreMessages = (messages: unknown): AiChatMessage[] =>
+  (Array.isArray(messages) ? messages : [])
+    .filter(isRecord)
+    .flatMap((message) => {
+      if (typeof message.id !== 'string' || (message.role !== 'user' && 
message.role !== 'ai')) return [];
+      return [{
+        id: message.id,
+        role: message.role as AiChatMessage['role'],
+        createdAt: typeof message.createdAt === 'number' ? message.createdAt : 
undefined,
+        text: truncateMessageField(message.text),
+        summary: truncateMessageField(message.summary),
+        thinking: truncateMessageField(message.thinking),
+        pending: false,
+      }];
+    })
+    .slice(-MAX_AI_CHAT_MESSAGES);
+
+const limitHistorySize = (history: AiChatHistory): AiChatHistory => {
+  const conversations = history.conversations.map((conversation) => ({ 
...conversation, messages: [...conversation.messages] }));
+  while (conversations.length > 0 && JSON.stringify({ conversations }).length 
> MAX_AI_CHAT_HISTORY_BYTES) {
+    const oldestIndex = conversations.length - 1;
+    const oldest = conversations[oldestIndex];
+    if (oldest.messages.length > 1) {
+      conversations[oldestIndex] = { ...oldest, messages: 
oldest.messages.slice(1) };
+    } else {
+      conversations.pop();
+    }
+  }
+  return {
+    conversations,
+    activeConversationId: conversations.some((item) => item.id === 
history.activeConversationId)
+      ? history.activeConversationId
+      : conversations[0]?.id ?? null,
+  };
+};
+
+export const getRecentAiChatConversations = (
+  conversations: AiChatConversation[],
+  limit = 8,
+): RecentAiChatConversation[] =>
+  conversations
+    .map((conversation) => ({
+      ...conversation,
+      prompt: conversation.messages.find((item) => item.role === 'user' && 
item.text?.trim())?.text,
+    }))
+    .filter((conversation): conversation is RecentAiChatConversation => 
Boolean(conversation.prompt))
+    .slice(0, limit);
+
+const restoreHistory = (history?: Partial<AiChatHistory> & { messages?: 
AiChatMessage[]; conversationId?: string | null }): AiChatHistory => {
+  if (Array.isArray(history?.conversations)) {
+    const conversations = history.conversations
+      .filter((conversation): conversation is AiChatConversation => 
isRecord(conversation) && typeof conversation.id === 'string')
+      .slice(0, MAX_AI_CHAT_CONVERSATIONS)
+      .map((conversation) => ({
+        id: conversation.id,
+        messages: restoreMessages(conversation.messages),
+        updatedAt: typeof conversation.updatedAt === 'number' ? 
conversation.updatedAt : 0,
+      }));
+    return limitHistorySize({
+      conversations,
+      activeConversationId: conversations.some((item) => item.id === 
history.activeConversationId)
+        ? history.activeConversationId ?? null
+        : conversations[0]?.id ?? null,
+    });
+  }
+
+  if (history?.conversationId || history?.messages?.length) {
+    const id = history.conversationId ?? 'legacy-conversation';
+    return limitHistorySize({
+      conversations: [{ id, messages: restoreMessages(history.messages), 
updatedAt: 0 }],
+      activeConversationId: id,
+    });
+  }
+  return emptyHistory();
+};
+
+let pendingPersist: { name: string; value: string } | null = null;
+let persistTimer: ReturnType<typeof setTimeout> | null = null;
+
+export const flushAiChatHistoryPersistence = (): void => {
+  if (persistTimer) {
+    clearTimeout(persistTimer);
+    persistTimer = null;
+  }
+  const pending = pendingPersist;
+  pendingPersist = null;
+  if (!pending) return;
+  try {
+    sessionStorage.setItem(pending.name, pending.value);
+  } catch {
+    // The in-memory conversation remains available if browser storage is 
unavailable.
+  }
+};
+
+const throttledHistoryStorage: StateStorage = {
+  getItem: (name) => sessionStorage.getItem(name),
+  setItem: (name, value) => {
+    pendingPersist = { name, value };
+    if (persistTimer) return;
+    persistTimer = setTimeout(flushAiChatHistoryPersistence, 
AI_CHAT_HISTORY_PERSIST_INTERVAL_MS);
+  },
+  removeItem: (name) => {
+    if (persistTimer) {
+      clearTimeout(persistTimer);
+      persistTimer = null;
+    }
+    pendingPersist = null;
+    sessionStorage.removeItem(name);
+  },
+};
+
+export const useAiChatHistoryStore = create<AiChatHistoryState>()(
+  persist(
+    (set) => ({
+      histories: { mock: emptyHistory(), real: emptyHistory() },
+      startConversation: (mode, conversationId) =>
+        set((state) => {
+          const history = state.histories[mode];
+          const exists = history.conversations.some((item) => item.id === 
conversationId);
+          const nextHistory = limitHistorySize({
+            conversations: exists
+              ? history.conversations
+              : [
+                  { id: conversationId, messages: [], updatedAt: Date.now() },
+                  ...history.conversations,
+                ].slice(0, MAX_AI_CHAT_CONVERSATIONS),
+            activeConversationId: conversationId,
+          });
+          return {
+            histories: {
+              ...state.histories,
+              [mode]: nextHistory,
+            },
+          };
+        }),
+      selectConversation: (mode, conversationId) =>
+        set((state) => ({
+          histories: {
+            ...state.histories,
+            [mode]: { ...state.histories[mode], activeConversationId: 
conversationId },
+          },
+        })),
+      setMessages: (mode, conversationId, messages) =>
+        set((state) => {
+          const history = state.histories[mode];
+          const conversation = history.conversations.find((item) => item.id 
=== conversationId);
+          const nextMessages = boundMessageFields(
+            typeof messages === 'function' ? messages(conversation?.messages 
?? []) : messages,
+          );
+          const updatedConversation = { id: conversationId, messages: 
nextMessages, updatedAt: Date.now() };
+          const nextHistory = limitHistorySize({
+            conversations: [updatedConversation, 
...history.conversations.filter((item) => item.id !== conversationId)].slice(
+              0,
+              MAX_AI_CHAT_CONVERSATIONS,
+            ),
+            activeConversationId: history.activeConversationId ?? 
conversationId,
+          });
+          return {
+            histories: {
+              ...state.histories,
+              [mode]: nextHistory,
+            },
+          };
+        }),
+      clearHistories: () => set({ histories: { mock: emptyHistory(), real: 
emptyHistory() } }),
+    }),
+    {
+      name: AI_CHAT_HISTORY_STORAGE_KEY,
+      storage: createJSONStorage(() => throttledHistoryStorage),
+      partialize: (state) => ({ histories: state.histories }),
+      merge: (persisted, current) => {
+        const saved = persisted as Partial<AiChatHistoryState> & { messages?: 
AiChatMessage[]; conversationId?: string | null };
+        return {
+          ...current,
+          histories: {
+            mock: restoreHistory(saved.histories?.mock),
+            real: restoreHistory(saved.histories?.real ?? saved),
+          },
+        };
+      },
+    },
+  ),
+);
+
+export const clearAiChatHistories = (): void => {
+  useAiChatHistoryStore.getState().clearHistories();
+  flushAiChatHistoryPersistence();
+};
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index d0696686f..b6371b5b0 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -1,6 +1,6 @@
 // SPDX-License-Identifier: Apache-2.0
 import { describe, expect, it } from 'vitest';
-import { formatBytes } from './format';
+import { formatBytes, formatRelativeTime, formatTimeOfDay } from './format';
 
 describe('formatBytes', () => {
   it('formats zero', () => {
@@ -23,4 +23,18 @@ describe('formatBytes', () => {
     expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('-');
     expect(formatBytes(Number.NEGATIVE_INFINITY)).toBe('-');
   });
+
+  it('formats recent timestamps for compact conversation history', () => {
+    const now = new Date(2026, 7, 13, 15, 30).getTime();
+    const zh = (key: string, params?: Record<string, string | number>) =>
+      key === 'ai.history.justNow' ? '刚刚' : `${params?.count} 分钟前`;
+    const en = (key: string, params?: Record<string, string | number>) =>
+      key === 'ai.history.justNow' ? 'Just now' : `${params?.count} min ago`;
+
+    expect(formatRelativeTime(now, 'zh', zh, now)).toBe('刚刚');
+    expect(formatRelativeTime(now - 5 * 60_000, 'zh', zh, now)).toBe('5 分钟前');
+    expect(formatRelativeTime(now - 5 * 60_000, 'en', en, now)).toBe('5 min 
ago');
+    expect(formatRelativeTime(now - 2 * 60 * 60_000, 'zh', zh, 
now)).toBe('13:30');
+    expect(formatTimeOfDay(now)).toBe('15:30');
+  });
 });
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 213c5b5d9..81fd6d044 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -40,6 +40,41 @@ export function formatDate(date: string | Date | null | 
undefined): string {
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
 }
 
+type RelativeTimeTranslator = (key: string, params?: Record<string, string | 
number>) => string;
+
+/**
+ * Format a recent timestamp for compact conversation history entries.
+ */
+export function formatRelativeTime(
+  timestamp: number,
+  lang: 'zh' | 'en',
+  t: RelativeTimeTranslator,
+  now = Date.now(),
+): string {
+  if (!timestamp) return t('ai.history.justNow');
+
+  const elapsed = Math.max(0, now - timestamp);
+  const minutes = Math.floor(elapsed / 60_000);
+  if (minutes < 1) return t('ai.history.justNow');
+  if (minutes < 60) return t('ai.history.minutesAgo', { count: minutes });
+
+  const updatedAt = new Date(timestamp);
+  const current = new Date(now);
+  const locale = lang === 'zh' ? 'zh-CN' : 'en-US';
+  if (updatedAt.toDateString() === current.toDateString()) {
+    return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: 
'2-digit', hour12: false }).format(updatedAt);
+  }
+  return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' 
}).format(updatedAt);
+}
+
+/**
+ * Format a message timestamp for a compact chat bubble footer.
+ */
+export function formatTimeOfDay(timestamp: number): string {
+  const date = new Date(timestamp);
+  return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
 /**
  * Format bytes into human-readable string (1024-based).
  * e.g. 1536 → '1.5 KB', 1048576 → '1 MB'

Reply via email to