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

Reply via email to