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'