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 96594bb8a fix(ai): persist the LLM api key and keep the AI page usable
without a config (#2482)
96594bb8a is described below
commit 96594bb8a66ac80e8773ff068a7b3a1b0875c9f5
Author: lizhimins <[email protected]>
AuthorDate: Fri Aug 21 15:57:30 2026 +0800
fix(ai): persist the LLM api key and keep the AI page usable without a
config (#2482)
The apiKey field is WRITE_ONLY so plain JSON serialization dropped it
from rmq_settings and the token was lost on every restart; persist it
explicitly (still hidden from API responses). Redirect CLI stdin from
/dev/null in both complete() and stream() so the claude CLI stops
waiting 3s and leaking its stdin warning into replies. The AI page
not-enabled hint becomes a compact neutral InfoBanner with a 去配置
link instead of the oversized yellow warning Alert.
---
.../studio/ops/ai/ClaudeCodeAgentProvider.java | 11 +++++++++
.../rocketmq/studio/ops/ai/CliAgentProvider.java | 9 ++++++++
.../persistence/MybatisPlusSettingsRepository.java | 9 +++++++-
.../studio/ops/ai/ClaudeCodeAgentProviderTest.java | 1 +
.../MybatisPlusSettingsRepositoryTest.java | 22 ++++++++++++++++++
web/src/pages/ai/index.tsx | 26 +++++++++++++---------
6 files changed, 66 insertions(+), 12 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
index 8646ca74d..c7038dd80 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
@@ -98,9 +98,20 @@ public class ClaudeCodeAgentProvider extends
CliAgentProvider {
ProcessBuilder builder = new ProcessBuilder(command);
processEnvironment().apply(builder, childEnv(config));
builder.redirectErrorStream(false);
+ // The claude CLI waits 3s for stdin and emits a warning that leaks
into the
+ // reply unless stdin is explicitly /dev/null; fall back to closing
the pipe
+ // on platforms without it.
+ java.io.File devNull = new java.io.File("/dev/null");
+ boolean devNullAvailable = devNull.exists();
+ if (devNullAvailable) {
+ builder.redirectInput(ProcessBuilder.Redirect.from(devNull));
+ }
Process process = null;
try {
process = startProcess(builder);
+ if (!devNullAvailable) {
+ process.getOutputStream().close();
+ }
AtomicBoolean emitted = new AtomicBoolean(false);
StringBuilder resultText = new StringBuilder();
CompletableFuture<Void> stdoutFuture = drainStdout(
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
index f038c0ff4..d2db6d3f1 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
@@ -91,9 +91,18 @@ public abstract class CliAgentProvider implements
AgentProvider {
// child stays alive. With the merged stream drained in the
background, waitFor can enforce
// the timeout and a hung child is destroyed instead of leaking the
caller thread.
builder.redirectErrorStream(true);
+ // CLIs wait for piped stdin and emit a warning that leaks into the
reply unless
+ // stdin is explicitly /dev/null; fall back to closing the pipe where
unavailable.
+ boolean devNullAvailable = new java.io.File("/dev/null").exists();
+ if (devNullAvailable) {
+ builder.redirectInput(ProcessBuilder.Redirect.from(new
java.io.File("/dev/null")));
+ }
Process process;
try {
process = builder.start();
+ if (!devNullAvailable) {
+ process.getOutputStream().close();
+ }
} catch (IOException exception) {
throw new LlmGatewayException(502, "llm.provider.io_error",
"Failed to execute " + binaryName() + " CLI",
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
index c4b26bb34..7bf0be11e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
@@ -94,7 +94,14 @@ public class MybatisPlusSettingsRepository implements
SettingsRepository {
@Transactional
public void saveGeneralSettings(GeneralSettingsVO settings) {
try {
- String json = objectMapper.writeValueAsString(settings);
+ com.fasterxml.jackson.databind.node.ObjectNode node =
+ (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.valueToTree(settings);
+ if
(org.springframework.util.StringUtils.hasText(settings.getApiKey())) {
+ // apiKey is WRITE_ONLY (hidden from API responses), so plain
serialization
+ // drops it; re-add it here or the configured LLM token is
lost on restart.
+ node.put("apiKey", settings.getApiKey());
+ }
+ String json = objectMapper.writeValueAsString(node);
RmqSettings entity = findSingletonSettings();
if (entity == null) {
entity = new RmqSettings();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
index a0bfc00cb..7786839ce 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
@@ -87,6 +87,7 @@ class ClaudeCodeAgentProviderTest {
CountDownLatch waitStarted = new CountDownLatch(1);
when(process.getInputStream()).thenReturn(new ByteArrayInputStream(new
byte[0]));
when(process.getErrorStream()).thenReturn(new ByteArrayInputStream(new
byte[0]));
+ when(process.getOutputStream()).thenReturn(new
java.io.ByteArrayOutputStream());
doAnswer(invocation -> {
waitStarted.countDown();
new CountDownLatch(1).await();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepositoryTest.java
index 7541552ff..1a1ad1d57 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepositoryTest.java
@@ -128,4 +128,26 @@ class MybatisPlusSettingsRepositoryTest {
assertThat(repository.replaceDataSource(replacement)).isFalse();
}
+
+ @Test
+ void shouldPersistAndReloadLlmApiKeyTest() {
+ RmqSettings stored = new RmqSettings();
+
when(settingsMapper.selectOne(any())).thenReturn(null).thenReturn(stored);
+
when(settingsMapper.insert(any(RmqSettings.class))).thenAnswer(invocation -> {
+ RmqSettings entity = invocation.getArgument(0);
+ stored.setJson(entity.getJson());
+ return 1;
+ });
+
+ GeneralSettingsVO settings = GeneralSettingsVO.builder()
+ .theme("system")
+ .llmProvider("tongyi")
+ .llmEngine("claude-code")
+ .apiKey("sk-roundtrip-token")
+ .build();
+ repository.saveGeneralSettings(settings);
+
+ assertThat(stored.getJson()).contains("sk-roundtrip-token");
+
assertThat(repository.loadGeneralSettings().getApiKey()).isEqualTo("sk-roundtrip-token");
+ }
}
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 536afc892..88942e649 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -56,6 +56,7 @@ 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 InfoBanner from '../../components/InfoBanner';
import useAuthStore from '../../stores/authStore';
import {
getRecentAiChatConversations,
@@ -935,18 +936,21 @@ const AiPage = () => {
</Flex>
{llmConfig && !llmReady && (
- <Alert
- type="warning"
- showIcon
- style={{ marginBottom: 12 }}
- message="AI 助手未启用"
+ <InfoBanner
+ title="AI 助手未启用"
description={t('ai.providerNotReadyDescription')}
- action={
- <Button size="small" onClick={() =>
navigate('/settings?tab=ai')}>
- 去配置
- </Button>
- }
- />
+ style={{ marginBottom: 12 }}
+ data-testid="ai-not-ready-banner"
+ >
+ <Button
+ type="link"
+ size="small"
+ style={{ paddingLeft: 0, marginTop: 4 }}
+ onClick={() => navigate('/settings?tab=ai')}
+ >
+ 去配置
+ </Button>
+ </InfoBanner>
)}
{!settingsHintDismissed && (
<Alert