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 42a7dd8cd fix(ai): consolidate Studio workspace stability (#2955)
42a7dd8cd is described below
commit 42a7dd8cd5f087cb165bd4492030e08dc6e03f86
Author: aias00 <[email protected]>
AuthorDate: Wed Sep 2 15:01:19 2026 +0800
fix(ai): consolidate Studio workspace stability (#2955)
* fix(ai): stabilize Studio chat handoff and streaming
Preserve the selected model, engine, and prompt enhancement state when
moving from Home into the AI workspace, and make slow streamed HTTP responses
fail visibly instead of disappearing.
Constraint: Keep this limited to AI workspace behavior without duplicating
LLM settings persistence changes.
Tested: pending combined verification after second commit.
Confidence: high
Scope-risk: moderate
Signed-off-by: liuhy <[email protected]>
* fix(ai): open workspace history from Home
Use an explicit one-shot route intent from the Home history action so the
AI page opens its existing history drawer without restoring a conversation
unless the user selects one.
Constraint: Keep history ownership in the AI workspace and leave Home as a
navigation entry point.
Tested: pending combined verification.
Confidence: high
Scope-risk: narrow
Signed-off-by: liuhy <[email protected]>
---------
Signed-off-by: liuhy <[email protected]>
---
.../studio/ops/ai/OpenAiCompatibleLlmClient.java | 13 +++-
.../studio/ops/ai/OpenAiCompatibleLlmGateway.java | 5 +-
.../ops/ai/OpenAiCompatibleLlmClientTest.java | 7 +-
.../ops/ai/OpenAiCompatibleLlmGatewayTest.java | 20 +++++
web/src/pages/ai/__tests__/AiMessage.test.tsx | 16 ++++
web/src/pages/ai/__tests__/AiPage.test.tsx | 85 ++++++++++++++++++++-
web/src/pages/ai/chatDraft.test.ts | 9 ++-
web/src/pages/ai/chatDraft.ts | 14 ++++
web/src/pages/ai/index.tsx | 88 +++++++++++++++++++---
web/src/pages/home/__tests__/HomePage.test.tsx | 55 ++++++++++++++
web/src/pages/home/index.tsx | 14 +++-
11 files changed, 305 insertions(+), 21 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
index 054a98765..654727609 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
@@ -56,9 +56,14 @@ import java.util.function.Consumer;
@Component
public class OpenAiCompatibleLlmClient {
+ private static final String MARKDOWN_SYSTEM_PROMPT = """
+ Format responses as valid CommonMark Markdown. Put a space after
heading and list markers,
+ put code fence languages on their own line, and keep code contents
inside fenced code blocks.
+ """;
private static final String CHAT_COMPLETIONS_PATH = "/chat/completions";
private static final String MODELS_PATH = "/models";
private static final int MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
+ private static final Duration DEFAULT_REQUEST_TIMEOUT =
Duration.ofMinutes(2);
private static final Set<String> SUPPORTED_PROVIDERS = Set.of("openai",
"deepseek", "tongyi", "ollama");
private final ObjectMapper objectMapper;
@@ -70,7 +75,7 @@ public class OpenAiCompatibleLlmClient {
this(objectMapper, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NEVER)
- .build(), Duration.ofSeconds(60));
+ .build(), DEFAULT_REQUEST_TIMEOUT);
}
OpenAiCompatibleLlmClient(ObjectMapper objectMapper, HttpClient
httpClient, Duration requestTimeout) {
@@ -300,9 +305,9 @@ public class OpenAiCompatibleLlmClient {
boolean stream) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", StringUtils.hasText(modelOverride) ?
modelOverride.trim() : config.getModel().trim());
- body.put("messages", List.of(Map.of(
- "role", "user",
- "content", StringUtils.hasText(prompt) ? prompt.trim() : "")));
+ body.put("messages", List.of(
+ Map.of("role", "system", "content", MARKDOWN_SYSTEM_PROMPT),
+ Map.of("role", "user", "content", StringUtils.hasText(prompt)
? prompt.trim() : "")));
body.put("temperature", config.getTemperature());
body.put("max_tokens", config.getMaxTokens());
body.put("stream", stream);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
index efdb6d1d4..cc1d55da7 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
@@ -47,7 +47,8 @@ import java.util.function.LongFunction;
@Component
public class OpenAiCompatibleLlmGateway implements LlmGateway {
- private static final long HTTP_STREAM_TIMEOUT_MILLIS = 60_000L;
+ // Keep this above the client timeout so provider timeouts can be sent as
SSE errors.
+ private static final long HTTP_STREAM_TIMEOUT_MILLIS = 125_000L;
private static final long CLI_STREAM_TIMEOUT_MILLIS = 300_000L;
private static final int MAX_CONCURRENT_CHATS = 16;
@@ -95,6 +96,8 @@ public class OpenAiCompatibleLlmGateway implements LlmGateway
{
return errorEmitter(incompleteConfigException());
}
String engine = resolveEngine(request == null ? null :
request.getEngine(), config);
+ log.info("Starting AI chat: engine={}, model={}", engine,
+ request == null ? null : request.getModel());
if (isCliEngine(engine)) {
return submitChat(CLI_STREAM_TIMEOUT_MILLIS,
session -> runCliChat(request, config, engine, session));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
index f10a908de..619284bc8 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
@@ -75,8 +75,11 @@ class OpenAiCompatibleLlmClientTest {
assertThat(authorization.get()).isEqualTo("Bearer sk-test");
assertThat(requestBody.get().path("model").asText()).isEqualTo("gpt-4o-mini");
assertThat(requestBody.get().path("stream").asBoolean()).isFalse();
-
assertThat(requestBody.get().path("messages").path(0).path("role").asText()).isEqualTo("user");
-
assertThat(requestBody.get().path("messages").path(0).path("content").asText()).isEqualTo("hello");
+
assertThat(requestBody.get().path("messages").path(0).path("role").asText()).isEqualTo("system");
+
assertThat(requestBody.get().path("messages").path(0).path("content").asText())
+ .contains("valid CommonMark Markdown");
+
assertThat(requestBody.get().path("messages").path(1).path("role").asText()).isEqualTo("user");
+
assertThat(requestBody.get().path("messages").path(1).path("content").asText()).isEqualTo("hello");
assertThat(requestBody.get().path("max_tokens").asInt()).isEqualTo(256);
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGatewayTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGatewayTest.java
index 23074ab12..0745de523 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGatewayTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGatewayTest.java
@@ -64,6 +64,24 @@ class OpenAiCompatibleLlmGatewayTest {
verify(llmClient, never()).supports(any(LlmConfigVO.class));
}
+ @Test
+ void httpChatAllowsSlowProvidersToStartStreamingTest() throws Exception {
+ ExecutorService executor = singleChatExecutor();
+ List<RecordingSseEmitter> emitters = new CopyOnWriteArrayList<>();
+ OpenAiCompatibleLlmGateway testedGateway = gateway(executor, emitters);
+ LlmConfigVO config = config("openai", "sk-test");
+ when(configService.getConfig()).thenReturn(config);
+ when(llmClient.supports(config)).thenReturn(true);
+ try {
+ testedGateway.chat(ChatDTO.builder().message("hello").build());
+
+ assertThat(emitters).hasSize(1);
+
assertThat(emitters.get(0).timeoutMillis).isEqualTo(TimeUnit.SECONDS.toMillis(125));
+ } finally {
+ testedGateway.destroy();
+ }
+ }
+
@Test
void executeShouldRejectIncompleteConfig() {
when(configService.getConfig()).thenReturn(config("openai", ""));
@@ -279,6 +297,7 @@ class OpenAiCompatibleLlmGatewayTest {
}
private static final class RecordingSseEmitter extends SseEmitter {
+ private final long timeoutMillis;
private final List<Set<ResponseBodyEmitter.DataWithMediaType>>
sentEvents = new CopyOnWriteArrayList<>();
private final CountDownLatch completedLatch = new CountDownLatch(1);
private Runnable completionCallback;
@@ -288,6 +307,7 @@ class OpenAiCompatibleLlmGatewayTest {
RecordingSseEmitter(long timeout) {
super(timeout);
+ timeoutMillis = timeout;
}
@Override
diff --git a/web/src/pages/ai/__tests__/AiMessage.test.tsx
b/web/src/pages/ai/__tests__/AiMessage.test.tsx
index 84571161a..5b792f7ec 100644
--- a/web/src/pages/ai/__tests__/AiMessage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiMessage.test.tsx
@@ -49,4 +49,20 @@ describe('AiMessage', () => {
expect(screen.getByText('mqadmin clusterList')).toBeInTheDocument();
expect(screen.getByText('mqadmin
clusterList').closest('pre')).toBeInTheDocument();
});
+
+ it('normalizes common malformed Markdown markers from model responses', ()
=> {
+ render(
+ <AiMessage
+ msg={{
+ id: 'ai-2',
+ role: 'ai',
+ summary: ['##结论', '-第一项', '', '```bashmqadmin clusterList',
'```'].join('\n'),
+ }}
+ />,
+ );
+
+ expect(screen.getByRole('heading', { name: '结论', level: 2
})).toBeInTheDocument();
+ expect(screen.getByRole('listitem')).toHaveTextContent('第一项');
+ 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 19d042b51..19b0aa9da 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -26,6 +26,7 @@ import { listClusters, type ClusterInfo } from
'../../../api/cluster';
import { getLlmConfig, getLlmModels } from '../../../api/llm';
import { useAiChatHistoryStore } from '../../../stores/aiChatHistoryStore';
import useAuthStore from '../../../stores/authStore';
+import { useEngineStore } from '../../../stores/engineStore';
import AiPage from '../index';
const dataModeMocks = vi.hoisted(() => ({ useMock: false }));
@@ -166,6 +167,37 @@ describe('AiPage tool runner', () => {
});
});
+ it('keeps the engine selected on the home page and exposes it in the AI
toolbar', async () => {
+ vi.mocked(chatStream).mockResolvedValue(undefined);
+ renderPage({ prompt: '检查集群状态', engine: 'qoder' });
+
+ await waitFor(() => {
+ expect(chatStream).toHaveBeenCalledWith(
+ expect.objectContaining({ message: '检查集群状态', engine: 'qoder' }),
+ expect.any(Function),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
+ });
+ expect(screen.getAllByTitle('执行引擎')[0]).toHaveTextContent('Qoder');
+ expect(useEngineStore.getState().engine).toBe('qoder');
+ });
+
+ it('keeps prompt enhancement enabled after a home-page draft is opened',
async () => {
+ vi.mocked(chatStream).mockResolvedValue(undefined);
+ renderPage({ prompt: '检查集群状态', enhance: true });
+
+ await waitFor(() => {
+ expect(chatStream).toHaveBeenCalledWith(
+ expect.objectContaining({ message: '检查集群状态', enhance: true }),
+ expect.any(Function),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
+ });
+ expect(screen.getByTitle('发送前增强 Prompt')).toHaveStyle({ borderColor:
'#1677ff' });
+ });
+
it('starts a new conversation when the home-page draft requests it', async
() => {
useAiChatHistoryStore.setState({
histories: {
@@ -269,6 +301,56 @@ describe('AiPage tool runner', () => {
expect(chatStream).not.toHaveBeenCalled();
});
+ it('opens the history drawer once when the route carries history intent',
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: null,
+ },
+ },
+ });
+
+ renderPage({ historyIntent: 'open' });
+
+ const historyDrawer = await screen.findByRole('dialog', { name: 'AI 对话历史'
});
+ expect(
+ within(historyDrawer).getByRole('button', { name: /^Previous
conversation/ }),
+ ).toBeInTheDocument();
+ expect(chatStream).not.toHaveBeenCalled();
+ });
+
+ it('does not reopen history when the route has no history intent', 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: null,
+ },
+ },
+ });
+
+ renderPage();
+
+ await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+ expect(screen.queryByRole('dialog', { name: 'AI 对话历史'
})).not.toBeInTheDocument();
+ expect(chatStream).not.toHaveBeenCalled();
+ });
+
it('stops an in-flight response before switching conversations', async () =>
{
useAiChatHistoryStore.setState({
histories: {
@@ -301,6 +383,7 @@ describe('AiPage tool runner', () => {
renderPage({ prompt: 'Start streaming', newConversation: true });
await waitFor(() => expect(requestSignal).toBeDefined());
+ expect(screen.getByRole('button', { name: '停止生成' })).toBeVisible();
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/ }));
@@ -308,7 +391,7 @@ describe('AiPage tool runner', () => {
expect(requestSignal?.aborted).toBe(true);
expect(useAiChatHistoryStore.getState().histories.real.activeConversationId).toBe('previous');
await waitFor(() =>
- expect(screen.queryByRole('button', { name: '停止'
})).not.toBeInTheDocument(),
+ expect(screen.queryByRole('button', { name: '停止生成'
})).not.toBeInTheDocument(),
);
});
diff --git a/web/src/pages/ai/chatDraft.test.ts
b/web/src/pages/ai/chatDraft.test.ts
index 734e90463..bd92fea43 100644
--- a/web/src/pages/ai/chatDraft.test.ts
+++ b/web/src/pages/ai/chatDraft.test.ts
@@ -16,7 +16,7 @@
*/
import { describe, expect, it } from 'vitest';
-import { getChatDraft } from './chatDraft';
+import { getChatDraft, shouldOpenChatHistory } from './chatDraft';
describe('AI chat draft navigation state', () => {
it('normalizes a prompt and preserves a selected model', () => {
@@ -47,4 +47,11 @@ describe('AI chat draft navigation state', () => {
prompt: '检查集群状态',
});
});
+
+ it('only opens history for the explicit history route intent', () => {
+ expect(shouldOpenChatHistory({ historyIntent: 'open' })).toBe(true);
+ expect(shouldOpenChatHistory({ historyIntent: 'closed' })).toBe(false);
+ expect(shouldOpenChatHistory({ prompt: '检查集群状态' })).toBe(false);
+ expect(shouldOpenChatHistory(null)).toBe(false);
+ });
});
diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/pages/ai/chatDraft.ts
index 17ed09034..eb0fc569d 100644
--- a/web/src/pages/ai/chatDraft.ts
+++ b/web/src/pages/ai/chatDraft.ts
@@ -15,13 +15,17 @@
* limitations under the License.
*/
+import type { AgentEngine } from '../../stores/engineStore';
+
export type ChatMode = 'chat' | 'diagnose' | 'manage' | 'query';
const CHAT_MODES = new Set<ChatMode>(['chat', 'diagnose', 'manage', 'query']);
+const AGENT_ENGINES = new Set<AgentEngine>(['claude-code', 'qoder', 'http']);
export interface ChatDraft {
prompt: string;
model?: string;
+ engine?: AgentEngine;
mode?: ChatMode;
enhance?: boolean;
newConversation?: boolean;
@@ -42,13 +46,23 @@ export function getChatDraft(state: unknown): ChatDraft |
null {
typeof candidate.mode === 'string' && CHAT_MODES.has(candidate.mode as
ChatMode)
? (candidate.mode as ChatMode)
: undefined;
+ const engine =
+ typeof candidate.engine === 'string' && AGENT_ENGINES.has(candidate.engine
as AgentEngine)
+ ? (candidate.engine as AgentEngine)
+ : undefined;
return {
prompt,
...(model ? { model } : {}),
+ ...(engine ? { engine } : {}),
...(mode ? { mode } : {}),
...(candidate.enhance === true ? { enhance: true } : {}),
...(candidate.newConversation === true ? { newConversation: true } : {}),
...(conversationId ? { conversationId } : {}),
};
}
+
+export function shouldOpenChatHistory(state: unknown): boolean {
+ if (typeof state !== 'object' || state === null) return false;
+ return (state as Record<string, unknown>).historyIntent === 'open';
+}
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 88942e649..cbc6a89f8 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -47,6 +47,7 @@ import {
ClockCounterClockwise,
SlidersHorizontal,
Sparkle,
+ Stop,
} from '@phosphor-icons/react';
import type { ColumnsType } from 'antd/es/table';
import { useLang } from '../../i18n/LangContext';
@@ -55,7 +56,7 @@ 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 { useEngineStore, type AgentEngine } from '../../stores/engineStore';
import InfoBanner from '../../components/InfoBanner';
import useAuthStore from '../../stores/authStore';
import {
@@ -64,7 +65,7 @@ import {
type AiChatDataMode,
useAiChatHistoryStore,
} from '../../stores/aiChatHistoryStore';
-import { getChatDraft, type ChatMode } from './chatDraft';
+import { getChatDraft, shouldOpenChatHistory, type ChatMode } from
'./chatDraft';
const { Text } = Typography;
@@ -124,6 +125,17 @@ const quickActions = [
];
const GLOBAL_TOOL_SCOPE = '__global__';
+const ENGINE_OPTIONS = [
+ { value: 'claude-code', label: 'Claude Code' },
+ { value: 'qoder', label: 'Qoder' },
+ { value: 'http', label: 'HTTP' },
+];
+
+const normalizeAiMarkdown = (content: string): string =>
+ content
+ .replace(/^(#{1,6})(?=\S)/gm, '$1 ')
+ .replace(/^([-+*])(?=\S)/gm, '$1 ')
+ .replace(/^```(bash|sh|shell|json|ya?ml|sql|text)(?=\S)/gim, '```$1\n');
const newConversationId = (): string =>
`conversation-${typeof crypto?.randomUUID === 'function' ?
crypto.randomUUID() : `${Date.now()}-${Math.random()}`}`;
@@ -395,7 +407,9 @@ export const AiMessage = ({ msg }: { msg: Message }) => {
{/* Summary text */}
{msg.summary && (
<div className="ai-markdown">
- <ReactMarkdown
remarkPlugins={[remarkGfm]}>{msg.summary}</ReactMarkdown>
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
+ {normalizeAiMarkdown(msg.summary)}
+ </ReactMarkdown>
</div>
)}
@@ -434,6 +448,8 @@ const AiPage = () => {
const useMock = useDataModeStore((state) => state.useMock);
const userId = useAuthStore((state) => state.userId);
const admin = useAuthStore((state) => state.admin);
+ const engine = useEngineStore((state) => state.engine);
+ const setEngine = useEngineStore((state) => state.setEngine);
const chatMode: AiChatDataMode = useMock ? 'mock' : 'real';
const { token } = theme.useToken();
const history = useAiChatHistoryStore((state) => state.histories[chatMode]);
@@ -474,6 +490,7 @@ const AiPage = () => {
const chatInFlightRef = useRef(false);
const toolLoadRequestRef = useRef(0);
const consumedDraftRef = useRef(false);
+ const draftEngineRef = useRef<AgentEngine | null>(null);
const pendingAutoSendRef = useRef<{
prompt: string;
model?: string;
@@ -514,6 +531,12 @@ const AiPage = () => {
try {
const config = await getLlmConfig();
setLlmConfig(config);
+ if (
+ draftEngineRef.current === null &&
+ (config.engine === 'http' || config.engine === 'claude-code' ||
config.engine === 'qoder')
+ ) {
+ setEngine(config.engine);
+ }
if (config?.model) {
setSelectedModel((current) => current || config.model);
}
@@ -534,7 +557,7 @@ const AiPage = () => {
} finally {
setModelsLoading(false);
}
- }, [canInspectLlmRuntime, t, useMock]);
+ }, [canInspectLlmRuntime, setEngine, t, useMock]);
useEffect(() => {
void Promise.resolve().then(loadLlmRuntime);
@@ -542,10 +565,16 @@ const AiPage = () => {
useEffect(() => {
const draft = getChatDraft(location.state);
- if (!draft || consumedDraftRef.current) return;
+ const openHistory = shouldOpenChatHistory(location.state);
+ if ((!draft && !openHistory) || consumedDraftRef.current) return;
consumedDraftRef.current = true;
void Promise.resolve().then(() => {
+ if (openHistory) setHistoryOpen(true);
+ if (!draft) {
+ navigate('/ai', { replace: true, state: null });
+ return;
+ }
if (draft.newConversation) {
const nextConversationId = newConversationId();
startConversation(chatMode, nextConversationId);
@@ -555,6 +584,11 @@ const AiPage = () => {
conversationIdRef.current = draft.conversationId;
}
if (draft.prompt) setInputValue(draft.prompt);
+ if (draft.enhance !== undefined) setEnhance(draft.enhance);
+ if (draft.engine) {
+ draftEngineRef.current = draft.engine;
+ setEngine(draft.engine);
+ }
const draftModel = draft.model;
if (draftModel) {
setSelectedModel(draftModel);
@@ -574,7 +608,7 @@ const AiPage = () => {
}
navigate('/ai', { replace: true, state: null });
});
- }, [chatMode, location.state, navigate, selectConversation,
startConversation]);
+ }, [chatMode, location.state, navigate, selectConversation, setEngine,
startConversation]);
/* ─── Auto-resize textarea ─── */
useEffect(() => {
@@ -645,7 +679,7 @@ const AiPage = () => {
message: text,
mode: modeOverride,
model,
- engine: useEngineStore.getState().engine,
+ engine,
enhance,
conversationId,
},
@@ -699,7 +733,17 @@ const AiPage = () => {
if (streamRequestIdRef.current === requestId) setLoading(false);
}
},
- [chatMode, inputValue, llmReady, loading, selectedModel,
startConversation, t, updateMessages],
+ [
+ chatMode,
+ engine,
+ inputValue,
+ llmReady,
+ loading,
+ selectedModel,
+ startConversation,
+ t,
+ updateMessages,
+ ],
);
/* ─── Auto-send the draft from the home page as soon as runtime is ready
─── */
@@ -997,6 +1041,17 @@ const AiPage = () => {
className="model-selector"
style={{ fontSize: '0.893rem' }}
/>
+ <Select
+ size="small"
+ value={engine}
+ onChange={(value) => setEngine(value as AgentEngine)}
+ options={ENGINE_OPTIONS}
+ variant="borderless"
+ popupMatchSelectWidth={false}
+ suffixIcon={<CaretDown size={10} color="#9CA3AF" />}
+ title="执行引擎"
+ style={{ fontSize: '0.893rem', minWidth: 110 }}
+ />
{llmConfig && (
<Tag color={llmReady ? 'green' : 'default'} style={{
borderRadius: 6 }}>
{llmConfig.provider || 'openai'}
@@ -1074,8 +1129,21 @@ const AiPage = () => {
<ArrowUp size={19} weight="bold" />
</button>
{loading && (
- <Button size="small" onClick={handleStop}>
- 停止
+ <Button
+ danger
+ type="primary"
+ size="middle"
+ icon={<Stop size={16} weight="fill" />}
+ onClick={handleStop}
+ title="停止生成"
+ style={{
+ height: 36,
+ borderRadius: 8,
+ fontWeight: 600,
+ boxShadow: '0 2px 8px rgba(255, 77, 79, 0.24)',
+ }}
+ >
+ 停止生成
</Button>
)}
</div>
diff --git a/web/src/pages/home/__tests__/HomePage.test.tsx
b/web/src/pages/home/__tests__/HomePage.test.tsx
index 0a0d3aada..0dcbf1f87 100644
--- a/web/src/pages/home/__tests__/HomePage.test.tsx
+++ b/web/src/pages/home/__tests__/HomePage.test.tsx
@@ -78,6 +78,32 @@ describe('HomePage LLM models', () => {
expect(await screen.findByText('qwen3.8-max')).toBeInTheDocument();
});
+ it('selects the model saved in the LLM configuration', async () => {
+ llmApiMocks.getLlmConfig.mockResolvedValue({
+ provider: 'deepseek',
+ apiBase: 'https://api.deepseek.com/v1',
+ model: 'deepseek-v4-flash',
+ maxTokens: 4096,
+ temperature: 0.7,
+ enabled: true,
+ ready: true,
+ });
+ const user = userEvent.setup();
+ renderHome();
+ await screen.findByText('deepseek-v4-flash');
+
+ await user.type(
+ screen.getByPlaceholderText('向 RocketMQ Bot 提问,全程加密、安全、可信'),
+ '查看集群状态{enter}',
+ );
+
+ await waitFor(() => {
+ expect(navigateMock).toHaveBeenCalledWith('/ai', {
+ state: expect.objectContaining({ model: 'deepseek-v4-flash' }),
+ });
+ });
+ });
+
it('does not fetch LLM config in Mock mode', async () => {
const { useDataModeStore } = await import('../../../stores/dataModeStore');
useDataModeStore.getState().toggle();
@@ -122,6 +148,35 @@ describe('HomePage LLM models', () => {
expect(navigateMock).not.toHaveBeenCalled();
});
+
+ it('opens the AI page with history intent from the accessible history
action', async () => {
+ const user = userEvent.setup();
+ renderHome();
+ await screen.findByText('qwen3.8-max');
+
+ const historyButton = screen.getByRole('button', { name: 'AI 对话历史' });
+ expect(historyButton).toHaveAttribute('type', 'button');
+ expect(historyButton).toHaveAttribute('title', 'AI 对话历史');
+
+ await user.click(historyButton);
+
+ expect(navigateMock).toHaveBeenCalledWith('/ai', {
+ state: { historyIntent: 'open' },
+ });
+ });
+
+ it('supports keyboard activation for the history action', async () => {
+ const user = userEvent.setup();
+ renderHome();
+ await screen.findByText('qwen3.8-max');
+
+ screen.getByRole('button', { name: 'AI 对话历史' }).focus();
+ await user.keyboard('{Enter}');
+
+ expect(navigateMock).toHaveBeenCalledWith('/ai', {
+ state: { historyIntent: 'open' },
+ });
+ });
});
describe('HomePage footer', () => {
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index 08c192a9f..15df7df21 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -135,7 +135,7 @@ const HomePage = () => {
})),
);
setSelectedModel((current) =>
- current && values.includes(current) ? current : values[0] || '',
+ current && values.includes(current) ? current : configuredModel ||
values[0] || '',
);
};
@@ -215,6 +215,10 @@ const HomePage = () => {
});
};
+ const handleHistoryOpen = () => {
+ navigate('/ai', { state: { historyIntent: 'open' } });
+ };
+
return (
<ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}>
<div
@@ -470,7 +474,13 @@ const HomePage = () => {
/>
</div>
<div className="flex shrink-0 items-center gap-1">
- <button className="p-1 rounded-md text-gray-400
hover:text-gray-600 hover:bg-gray-50 transition-colors">
+ <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={handleHistoryOpen}
+ >
<ClockCounterClockwise size={20} />
</button>
</div>