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 72a3bbdc1 fix(ai): authorize reader tools and degrade AI page 
gracefully (#2370)
72a3bbdc1 is described below

commit 72a3bbdc118b35801914c24b25a3499076a96f09
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 19 15:09:22 2026 +0800

    fix(ai): authorize reader tools and degrade AI page gracefully (#2370)
    
    Constraint: Reader access must be limited to low-risk read tools and must 
keep rmq.message.query / rmq.message.trace admin-only until broker ownership 
validation exists
    Rejected: POST path whitelist | Would bypass catalog permission checks and 
broaden future tool exposure
    Directive: Keep permission-aware authorization in both interceptor gating 
and tool execution; do not re-open message query/trace for readers without 
fixing remoting target validation
    Confidence: high
    Scope-risk: moderate
    Tested: 
JAVA_HOME=/Users/aias/Library/Java/JavaVirtualMachines/openjdk-21.0.2/Contents/Home
 mvn -q -Dtest=AuthInterceptorTest,ToolGatewayServiceTest,LlmControllerTest test
    Tested: 
JAVA_HOME=/Users/aias/Library/Java/JavaVirtualMachines/openjdk-21.0.2/Contents/Home
 mvn -q -DskipTests compile
    Tested: npm test -- --run src/pages/ai/__tests__/AiPage.test.tsx 
src/api/llm.test.ts
    Tested: npm run build
    Tested: npx eslint src/pages/ai/index.tsx 
src/pages/ai/__tests__/AiPage.test.tsx
    Tested: git diff --check
    
    Signed-off-by: liuhy <[email protected]>
---
 .../rocketmq/studio/auth/AuthInterceptor.java      |  25 +-
 .../apache/rocketmq/studio/auth/AuthWebConfig.java |   6 +-
 .../studio/auth/AuthenticatedUserContext.java      |   6 +
 .../studio/ops/ai/tool/ToolAccessPolicy.java       |  86 ++++
 .../studio/ops/ai/tool/ToolDefinition.java         |   8 +
 .../studio/ops/ai/tool/ToolGatewayService.java     |  10 +
 .../studio/ops/ai/tool/ToolPermission.java         |  38 ++
 .../rocketmq/studio/auth/AuthInterceptorTest.java  |  85 +++-
 .../studio/ops/ai/tool/ToolGatewayServiceTest.java |  79 ++++
 web/src/pages/ai/__tests__/AiPage.test.tsx         |  30 +-
 web/src/pages/ai/index.tsx                         | 473 +++++++++++----------
 11 files changed, 605 insertions(+), 241 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
index 3e8402f81..88666290a 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
@@ -19,7 +19,7 @@ package org.apache.rocketmq.studio.auth;
 
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
-import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.ops.ai.tool.ToolAccessPolicy;
 import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
 import org.apache.rocketmq.studio.settings.SettingsRepository;
 import org.springframework.http.HttpMethod;
@@ -30,7 +30,6 @@ import org.springframework.web.servlet.HandlerInterceptor;
 
 import java.util.Set;
 
-@RequiredArgsConstructor
 public class AuthInterceptor implements HandlerInterceptor {
 
     private static final Set<String> READER_POST_PATHS = Set.of(
@@ -43,6 +42,20 @@ public class AuthInterceptor implements HandlerInterceptor {
     private final AuthProperties authProperties;
     private final AuthService authService;
     private final SettingsRepository settingsRepository;
+    private final ToolAccessPolicy toolAccessPolicy;
+
+    public AuthInterceptor(AuthProperties authProperties, AuthService 
authService,
+                           SettingsRepository settingsRepository) {
+        this(authProperties, authService, settingsRepository, null);
+    }
+
+    public AuthInterceptor(AuthProperties authProperties, AuthService 
authService,
+                           SettingsRepository settingsRepository, 
ToolAccessPolicy toolAccessPolicy) {
+        this.authProperties = authProperties;
+        this.authService = authService;
+        this.settingsRepository = settingsRepository;
+        this.toolAccessPolicy = toolAccessPolicy;
+    }
 
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse 
response,
@@ -59,6 +72,7 @@ public class AuthInterceptor implements HandlerInterceptor {
             writeError(response, HttpStatus.UNAUTHORIZED, "Unauthorized");
             return false;
         }
+
         var authenticatedUser = 
authService.getAuthenticatedUser(authorization).orElse(null);
         if (authenticatedUser != null) {
             AuthenticatedUserContext.setUser(
@@ -102,12 +116,17 @@ public class AuthInterceptor implements 
HandlerInterceptor {
             // Read endpoints stay open to readers, except credential views 
that expose secrets.
             return isAdminOnlyGetPath(path);
         }
+        String normalizedPath = normalizePath(stripPathParameters(path));
+        if (toolAccessPolicy != null && 
toolAccessPolicy.isToolExecutionPath(normalizedPath)) {
+            return !toolAccessPolicy.isReaderAccessiblePath(normalizedPath);
+        }
         return !HttpMethod.POST.matches(method) || 
!READER_POST_PATHS.contains(normalizePath(path));
     }
 
     private boolean isAdminOnlyGetPath(String path) {
         String normalizedPath = normalizePath(stripPathParameters(path));
-        return "/api/llm/models".equals(normalizedPath)
+        return "/api/llm/config".equals(normalizedPath)
+                || "/api/llm/models".equals(normalizedPath)
                 || "/api/studio-users".equals(normalizedPath)
                 || isCloudCatalogPath(normalizedPath)
                 || "/api/acl/remote/rules".equals(normalizedPath)
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
index f4b1aec42..17d813147 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
@@ -18,6 +18,7 @@
 package org.apache.rocketmq.studio.auth;
 
 import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.ops.ai.tool.ToolAccessPolicy;
 import org.apache.rocketmq.studio.settings.SettingsRepository;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.context.annotation.Configuration;
@@ -37,6 +38,7 @@ public class AuthWebConfig implements WebMvcConfigurer {
     private final ObjectProvider<AuthProperties> authPropertiesProvider;
     private final ObjectProvider<AuthService> authServiceProvider;
     private final ObjectProvider<SettingsRepository> 
settingsRepositoryProvider;
+    private final ObjectProvider<ToolAccessPolicy> toolAccessPolicyProvider;
 
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
@@ -44,7 +46,9 @@ public class AuthWebConfig implements WebMvcConfigurer {
         // missing the interceptor falls back to the static login-required 
property (effectively
         // no enforcement), matching the old conditional-registration 
behaviour.
         registry.addInterceptor(new 
AuthInterceptor(authPropertiesProvider.getIfAvailable(),
-                        authServiceProvider.getIfAvailable(), 
settingsRepositoryProvider.getIfAvailable()))
+                        authServiceProvider.getIfAvailable(),
+                        settingsRepositoryProvider.getIfAvailable(),
+                        toolAccessPolicyProvider.getIfAvailable()))
                 .addPathPatterns("/api/**");
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
index f2bfcb045..f7dd46852 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
@@ -49,18 +49,24 @@ public final class AuthenticatedUserContext {
     }
 
     public static void setUser(Long userId, String username, boolean admin) {
+
         setUser(username, admin);
         if (userId == null) {
             CURRENT_USER_ID.remove();
         } else {
             CURRENT_USER_ID.set(String.valueOf(userId));
         }
+        CURRENT_ADMIN.set(admin);
     }
 
     public static String currentUserId() {
         return CURRENT_USER_ID.get();
     }
 
+    public static boolean currentUserIsAdmin() {
+        return Boolean.TRUE.equals(CURRENT_ADMIN.get());
+    }
+
     public static String currentUsernameOrSystem() {
         String username = CURRENT_USERNAME.get();
         return username == null ? SYSTEM_ACTOR : username;
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolAccessPolicy.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolAccessPolicy.java
new file mode 100644
index 000000000..b23bfedc7
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolAccessPolicy.java
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  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.
+ */
+package org.apache.rocketmq.studio.ops.ai.tool;
+
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.stereotype.Component;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.Set;
+
+@Component
+public class ToolAccessPolicy {
+
+    private static final String TOOL_EXECUTION_PREFIX = "/api/ai/tools/";
+    private static final String TOOL_EXECUTION_SUFFIX = "/execute";
+    /**
+     * These read-labeled tools still let callers steer RocketMQ remoting to 
user-influenced
+     * broker addresses via msgId-derived lookups, so readers must not gain 
access before the
+     * address-ownership validation bug is fixed.
+     */
+    private static final Set<String> READER_DENY_LIST = Set.of(
+            "rmq.message.query",
+            "rmq.message.trace");
+
+    private final ToolCatalog catalog;
+
+    public ToolAccessPolicy(ToolCatalog catalog) {
+        this.catalog = catalog;
+    }
+
+    public boolean isToolExecutionPath(String requestPath) {
+        return requestPath != null
+                && requestPath.startsWith(TOOL_EXECUTION_PREFIX)
+                && requestPath.endsWith(TOOL_EXECUTION_SUFFIX);
+    }
+
+    public boolean isReaderAccessiblePath(String requestPath) {
+        return resolveExecutionDefinition(requestPath)
+                .map(this::isReaderAccessible)
+                .orElse(false);
+    }
+
+    public boolean isReaderAccessible(ToolDefinition definition) {
+        return definition != null
+                && definition.isLowRiskReadOnly()
+                && !READER_DENY_LIST.contains(definition.name());
+    }
+
+    public void authorizeCurrentUser(ToolDefinition definition) {
+        if (AuthenticatedUserContext.currentUserIsAdmin() || 
isReaderAccessible(definition)) {
+            return;
+        }
+        throw new BusinessException(403, "Admin permission required");
+    }
+
+    private Optional<ToolDefinition> resolveExecutionDefinition(String 
requestPath) {
+        if (!isToolExecutionPath(requestPath)) {
+            return Optional.empty();
+        }
+        String encodedName = requestPath.substring(
+                TOOL_EXECUTION_PREFIX.length(),
+                requestPath.length() - TOOL_EXECUTION_SUFFIX.length());
+        if (encodedName.isBlank()) {
+            return Optional.empty();
+        }
+        String toolName = URLDecoder.decode(encodedName, 
StandardCharsets.UTF_8);
+        return catalog.find(toolName);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolDefinition.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolDefinition.java
index d026206d0..f466ed47f 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolDefinition.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolDefinition.java
@@ -45,6 +45,14 @@ public record ToolDefinition(
         return name;
     }
 
+    public ToolPermission parsedPermission() {
+        return ToolPermission.parse(permission);
+    }
+
+    public boolean isLowRiskReadOnly() {
+        return "L1".equalsIgnoreCase(riskLevel) && 
parsedPermission().isReadOnly();
+    }
+
     private static Map<String, Object> immutableMap(Map<String, Object> 
source) {
         Map<String, Object> copy = new LinkedHashMap<>();
         source.forEach((key, value) -> copy.put(key, immutableValue(value)));
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 8e6d3a761..1f910151a 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
@@ -24,6 +24,7 @@ import com.networknt.schema.SchemaLocation;
 import com.networknt.schema.SchemaRegistry;
 import com.networknt.schema.SpecificationVersion;
 import com.networknt.schema.dialect.Dialects;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.ops.ai.AiToolVO;
 import org.springframework.stereotype.Service;
@@ -47,15 +48,18 @@ public class ToolGatewayService {
     private final Map<String, ToolHandler> handlers;
     private final Map<String, Schema> inputSchemas;
     private final Map<String, Schema> outputSchemas;
+    private final ToolAccessPolicy toolAccessPolicy;
 
     public ToolGatewayService(
             ToolCatalog catalog,
             CapabilityResolver capabilityResolver,
             ObjectMapper objectMapper,
+            ToolAccessPolicy toolAccessPolicy,
             List<ToolHandler> handlers) {
         this.catalog = catalog;
         this.capabilityResolver = capabilityResolver;
         this.objectMapper = objectMapper;
+        this.toolAccessPolicy = toolAccessPolicy;
         this.handlers = registerHandlers(catalog, handlers);
 
         SchemaRegistry registry = SchemaRegistry.withDefaultDialect(
@@ -77,6 +81,7 @@ public class ToolGatewayService {
                         || !requiresCluster(definition))
                 .filter(definition -> 
discoveryCapabilities.capabilities().containsAll(
                         definition.requiredCapabilities()))
+                .filter(this::isVisibleToCurrentUser)
                 .map(ToolGatewayService::toView)
                 .toList();
     }
@@ -113,6 +118,7 @@ public class ToolGatewayService {
             throw new BusinessException(
                     400, "Execution rejected; only L1 tools are enabled: " + 
name);
         }
+        toolAccessPolicy.authorizeCurrentUser(definition);
         enforceCapabilities(definition, normalizedInput);
 
         Object output = handler.execute(normalizedInput);
@@ -219,6 +225,10 @@ public class ToolGatewayService {
         return required instanceof List<?> fields && 
fields.contains("cluster");
     }
 
+    private boolean isVisibleToCurrentUser(ToolDefinition definition) {
+        return AuthenticatedUserContext.currentUserIsAdmin() || 
toolAccessPolicy.isReaderAccessible(definition);
+    }
+
     private static AiToolVO toView(ToolDefinition definition) {
         return AiToolVO.builder()
                 .name(definition.name())
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolPermission.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolPermission.java
new file mode 100644
index 000000000..d5f5e5743
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ToolPermission.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  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.
+ */
+package org.apache.rocketmq.studio.ops.ai.tool;
+
+import org.springframework.util.StringUtils;
+
+import java.util.Locale;
+
+public record ToolPermission(String resource, String action) {
+
+    public static ToolPermission parse(String rawPermission) {
+        if (!StringUtils.hasText(rawPermission)) {
+            return new ToolPermission("", "");
+        }
+        String[] parts = rawPermission.trim().split(":", 2);
+        String resource = parts[0].trim().toLowerCase(Locale.ROOT);
+        String action = parts.length > 1 ? 
parts[1].trim().toLowerCase(Locale.ROOT) : "";
+        return new ToolPermission(resource, action);
+    }
+
+    public boolean isReadOnly() {
+        return "read".equals(action);
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index 5fc95a5fe..3fcd14867 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -18,12 +18,15 @@
 package org.apache.rocketmq.studio.auth;
 
 import org.junit.jupiter.api.AfterEach;
+import org.apache.rocketmq.studio.ops.ai.tool.ToolAccessPolicy;
+import org.apache.rocketmq.studio.ops.ai.tool.ToolCatalog;
 import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
 import org.apache.rocketmq.studio.settings.SettingsRepository;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.springframework.http.HttpHeaders;
+import org.springframework.core.io.DefaultResourceLoader;
 import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.mock.web.MockHttpServletResponse;
 
@@ -43,7 +46,7 @@ class AuthInterceptorTest {
     @Test
     void shouldAllowRequestsWhenLoginIsDisabled() throws Exception {
         AuthProperties properties = new AuthProperties();
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties), settingsRepository());
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties), settingsRepository());
         MockHttpServletRequest request = new MockHttpServletRequest("GET", 
"/api/clusters");
 
         boolean allowed = interceptor.preHandle(request, new 
MockHttpServletResponse(), new Object());
@@ -57,7 +60,7 @@ class AuthInterceptorTest {
     void shouldRejectProtectedApiWithoutTokenWhenLoginIsEnabled() throws 
Exception {
         AuthProperties properties = new AuthProperties();
         properties.setLoginRequired(true);
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties), settingsRepository());
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties), settingsRepository());
         MockHttpServletRequest request = new MockHttpServletRequest("GET", 
"/api/clusters");
         MockHttpServletResponse response = new MockHttpServletResponse();
 
@@ -78,7 +81,7 @@ class AuthInterceptorTest {
         user.setAdmin(true);
         properties.setUsers(List.of(user));
         AuthService authService = authService(properties);
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService, settingsRepository());
+        AuthInterceptor interceptor = interceptor(properties, authService, 
settingsRepository());
         LoginDTO login = new LoginDTO();
         login.setUsername("admin");
         login.setPassword("secret");
@@ -122,7 +125,7 @@ class AuthInterceptorTest {
     @Test
     void shouldEnforceLoginWhenDatabaseRequiresItEvenIfPropertyIsDisabled() 
throws Exception {
         AuthProperties properties = new AuthProperties();
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties),
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties),
                 settingsRepositoryRequiringLogin());
         MockHttpServletRequest request = new MockHttpServletRequest("GET", 
"/api/clusters");
         MockHttpServletResponse response = new MockHttpServletResponse();
@@ -139,7 +142,7 @@ class AuthInterceptorTest {
         SettingsRepository failingSettingsRepository = 
mock(SettingsRepository.class);
         when(failingSettingsRepository.loadGeneralSettings())
                 .thenThrow(new IllegalStateException("settings database 
unavailable"));
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties),
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties),
                 failingSettingsRepository);
         MockHttpServletRequest request = new MockHttpServletRequest("GET", 
"/api/clusters");
         MockHttpServletResponse response = new MockHttpServletResponse();
@@ -155,7 +158,7 @@ class AuthInterceptorTest {
     void shouldAllowLoginEndpointWhenLoginIsEnabled() throws Exception {
         AuthProperties properties = new AuthProperties();
         properties.setLoginRequired(true);
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties), settingsRepository());
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties), settingsRepository());
         MockHttpServletRequest request = new MockHttpServletRequest("POST", 
"/api/auth/login");
 
         boolean allowed = interceptor.preHandle(request, new 
MockHttpServletResponse(), new Object());
@@ -167,7 +170,7 @@ class AuthInterceptorTest {
     void shouldAllowLoginEndpointWithTrailingSlashWhenLoginIsEnabled() throws 
Exception {
         AuthProperties properties = new AuthProperties();
         properties.setLoginRequired(true);
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties), settingsRepository());
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties), settingsRepository());
         MockHttpServletRequest request = new MockHttpServletRequest("POST", 
"/api/auth/login/");
 
         boolean allowed = interceptor.preHandle(request, new 
MockHttpServletResponse(), new Object());
@@ -179,7 +182,7 @@ class AuthInterceptorTest {
     void shouldAllowAuthStatusEndpointWhenLoginIsEnabled() throws Exception {
         AuthProperties properties = new AuthProperties();
         properties.setLoginRequired(true);
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties), settingsRepository());
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties), settingsRepository());
         MockHttpServletRequest request = new MockHttpServletRequest("GET", 
"/api/auth/status");
 
         boolean allowed = interceptor.preHandle(request, new 
MockHttpServletResponse(), new Object());
@@ -191,7 +194,7 @@ class AuthInterceptorTest {
     void shouldAllowAuthStatusEndpointWithTrailingSlashWhenLoginIsEnabled() 
throws Exception {
         AuthProperties properties = new AuthProperties();
         properties.setLoginRequired(true);
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties), settingsRepository());
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties), settingsRepository());
         MockHttpServletRequest request = new MockHttpServletRequest("GET", 
"/api/auth/status/");
 
         boolean allowed = interceptor.preHandle(request, new 
MockHttpServletResponse(), new Object());
@@ -203,7 +206,7 @@ class AuthInterceptorTest {
     void shouldAllowHealthProbesWhenLoginIsEnabled() throws Exception {
         AuthProperties properties = new AuthProperties();
         properties.setLoginRequired(true);
-        AuthInterceptor interceptor = new AuthInterceptor(properties, 
authService(properties), settingsRepository());
+        AuthInterceptor interceptor = interceptor(properties, 
authService(properties), settingsRepository());
 
         for (String path : List.of("/livez", "/readyz")) {
             boolean allowed = interceptor.preHandle(new 
MockHttpServletRequest("GET", path),
@@ -296,6 +299,31 @@ class AuthInterceptorTest {
         assertThat(response.getStatus()).isEqualTo(403);
     }
 
+    @Test
+    void shouldRejectLlmConfigReadForNonAdminUser() throws Exception {
+        TestSession session = login(false);
+        MockHttpServletRequest request = authenticatedRequest(
+                "GET", "/api/llm/config", session.token());
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean allowed = session.interceptor().preHandle(request, response, 
new Object());
+
+        assertThat(allowed).isFalse();
+        assertThat(response.getStatus()).isEqualTo(403);
+    }
+
+    @Test
+    void shouldAllowLlmConfigReadForAdminUser() throws Exception {
+        TestSession session = login(true);
+        MockHttpServletRequest request = authenticatedRequest(
+                "GET", "/api/llm/config", session.token());
+
+        boolean allowed = session.interceptor().preHandle(
+                request, new MockHttpServletResponse(), new Object());
+
+        assertThat(allowed).isTrue();
+    }
+
     @Test
     void shouldAllowLlmModelDiscoveryForAdminUser() throws Exception {
         TestSession session = login(true);
@@ -346,6 +374,32 @@ class AuthInterceptorTest {
         assertThat(allowed).isTrue();
     }
 
+    @Test
+    void shouldAllowReaderSafeAiToolExecutionForNonAdminUser() throws 
Exception {
+        TestSession session = login(false);
+        MockHttpServletRequest request = authenticatedRequest(
+                "POST", "/api/ai/tools/rmq.capabilities/execute", 
session.token());
+
+        boolean allowed = session.interceptor().preHandle(
+                request, new MockHttpServletResponse(), new Object());
+
+        assertThat(allowed).isTrue();
+    }
+
+    @Test
+    void shouldRejectHighRiskAiToolExecutionForNonAdminUser() throws Exception 
{
+        TestSession session = login(false);
+        MockHttpServletRequest request = authenticatedRequest(
+                "POST", "/api/ai/tools/rmq.message.query/execute", 
session.token());
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean allowed = session.interceptor().preHandle(request, response, 
new Object());
+
+        assertThat(allowed).isFalse();
+        assertThat(response.getStatus()).isEqualTo(403);
+        assertThat(response.getContentAsString()).contains("Admin permission 
required");
+    }
+
     @Test
     void shouldAllowConfiguredDataSourceQueryForNonAdminUser() throws 
Exception {
         TestSession session = login(false);
@@ -503,7 +557,16 @@ class AuthInterceptorTest {
         login.setUsername("test-user");
         login.setPassword("secret");
         String token = authService.login(login).getToken();
-        return new TestSession(new AuthInterceptor(properties, authService, 
settingsRepository()), token);
+        return new TestSession(interceptor(properties, authService, 
settingsRepository()), token);
+    }
+
+    private AuthInterceptor interceptor(AuthProperties properties, AuthService 
authService,
+                                        SettingsRepository settingsRepository) 
{
+        return new AuthInterceptor(
+                properties,
+                authService,
+                settingsRepository,
+                new ToolAccessPolicy(new ToolCatalog(new 
DefaultResourceLoader())));
     }
 
     private MockHttpServletRequest authenticatedRequest(String method, String 
path, String token) {
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 78a9cefb2..69de63e16 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
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.ops.ai.tool;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.cluster.broker.ClusterService;
 import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
 import 
org.apache.rocketmq.studio.cluster.nameserver.NameServerConfigDiffService;
@@ -39,6 +40,7 @@ import 
org.apache.rocketmq.studio.ops.dashboard.ClusterOverviewVO;
 import org.apache.rocketmq.studio.ops.dashboard.DashboardDataVO;
 import org.apache.rocketmq.studio.ops.dashboard.DashboardService;
 import org.apache.rocketmq.studio.ops.dashboard.DashboardStatsVO;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.core.io.ByteArrayResource;
@@ -79,6 +81,7 @@ class ToolGatewayServiceTest {
 
     @BeforeEach
     void setUp() {
+        AuthenticatedUserContext.setUser(1L, "admin", true);
         catalog = canonicalCatalog();
         clusterService = mock(ClusterService.class);
         dashboardService = mock(DashboardService.class);
@@ -110,6 +113,11 @@ class ToolGatewayServiceTest {
                 messageTraceHandler);
     }
 
+    @AfterEach
+    void clearAuthenticatedUser() {
+        AuthenticatedUserContext.clear();
+    }
+
     @Test
     void discoveryWithoutClusterOnlyExposesClusterList() {
         assertThat(gateway.discover(null))
@@ -136,6 +144,24 @@ class ToolGatewayServiceTest {
                         "rmq.nameserver.config.diff");
     }
 
+    @Test
+    void discoveryForReaderFiltersSensitiveMessageTools() {
+        AuthenticatedUserContext.setUser(2L, "reader", false);
+        
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
+
+        assertThat(gateway.discover("cluster-v5"))
+                .extracting(AiToolVO::getName)
+                .contains(
+                        "rmq.cluster.list",
+                        "rmq.capabilities",
+                        "rmq.dashboard.summary",
+                        "rmq.topic.list",
+                        "rmq.group.list",
+                        "rmq.alert.rule.list",
+                        "rmq.nameserver.config.diff")
+                .doesNotContain("rmq.message.query", "rmq.message.trace");
+    }
+
     @Test
     void discoveryWithClusterOfUnknownTypeOnlyExposesGlobalTools() {
         
when(clusterService.getCluster("unknown")).thenReturn(cluster("unknown", null));
@@ -217,6 +243,21 @@ class ToolGatewayServiceTest {
         assertThat(output.toString()).doesNotContain("do-not-expose");
     }
 
+    @Test
+    void readerCanExecuteLowRiskReadOnlyTool() {
+        AuthenticatedUserContext.setUser(2L, "reader", false);
+        
when(clusterService.listClusters()).thenReturn(List.of(cluster(ClusterType.V5_PROXY_CLUSTER)));
+
+        Object output = gateway.execute("rmq.cluster.list", Map.of());
+
+        assertThat(output).isEqualTo(List.of(Map.of(
+                "id", "cluster-v5",
+                "name", "test",
+                "type", "V5_PROXY_CLUSTER",
+                "status", "healthy",
+                "version", "5.2.0")));
+    }
+
     @Test
     void executesCapabilitiesWithAStableSortedCapabilityList() {
         
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
@@ -487,6 +528,17 @@ class ToolGatewayServiceTest {
                 .hasMessageContaining("Tool not found");
     }
 
+    @Test
+    void readerCannotExecuteSensitiveMessageQueryTool() {
+        AuthenticatedUserContext.setUser(2L, "reader", false);
+
+        assertThatThrownBy(() -> gateway.execute(
+                "rmq.message.query", Map.of("cluster", "cluster-v5")))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Admin permission required");
+        verifyNoInteractions(messageService);
+    }
+
     @Test
     void refusesNonL1CatalogEntriesEvenWhenAHandlerIsRegistered() throws 
IOException {
         String yaml = canonicalCatalogText().replaceFirst("riskLevel: L1", 
"riskLevel: L2");
@@ -511,6 +563,32 @@ class ToolGatewayServiceTest {
         verifyNoInteractions(clusterService);
     }
 
+    @Test
+    void readerCannotExecuteCatalogEntriesWithoutReadPermission() throws 
IOException {
+        AuthenticatedUserContext.setUser(2L, "reader", false);
+        String yaml = canonicalCatalogText().replaceFirst(
+                "permission: cluster:read", "permission: cluster:write");
+        ToolCatalog writeCatalog = ToolCatalog.load(
+                new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)),
+                new ClassPathResource("tool-catalog/rmq-tools.schema.json"));
+        ToolGatewayService writeGateway = gateway(
+                writeCatalog,
+                clusterListHandler,
+                capabilitiesHandler,
+                dashboardSummaryHandler,
+                topicListHandler,
+                consumerGroupListHandler,
+                alertRuleListHandler,
+                nameServerConfigDiffHandler,
+                messageQueryHandler,
+                messageTraceHandler);
+
+        assertThatThrownBy(() -> writeGateway.execute("rmq.cluster.list", 
Map.of()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Admin permission required");
+        verifyNoInteractions(clusterService);
+    }
+
     @Test
     void failsStartupForDuplicateHandlerNames() {
         assertThatThrownBy(() -> gateway(
@@ -626,6 +704,7 @@ class ToolGatewayServiceTest {
                 toolCatalog,
                 capabilityResolver,
                 new ObjectMapper(),
+                new ToolAccessPolicy(toolCatalog),
                 List.of(handlers));
     }
 
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 5c782c6f4..19d042b51 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -25,6 +25,7 @@ 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 useAuthStore from '../../../stores/authStore';
 import AiPage from '../index';
 
 const dataModeMocks = vi.hoisted(() => ({ useMock: false }));
@@ -88,6 +89,7 @@ describe('AiPage tool runner', () => {
         real: { conversations: [], activeConversationId: null },
       },
     });
+    useAuthStore.setState({ user: null, userId: null, admin: null });
     vi.mocked(getLlmConfig).mockResolvedValue({
       provider: 'openai',
       apiBase: 'https://api.openai.com/v1',
@@ -134,6 +136,22 @@ describe('AiPage tool runner', () => {
     expect(listClusters).not.toHaveBeenCalled();
   });
 
+  it('degrades for reader accounts without loading model configuration', async 
() => {
+    useAuthStore.setState({ user: 'reader', userId: 9, admin: false });
+    const user = userEvent.setup();
+    renderPage();
+
+    await waitFor(() => {
+      expect(getLlmConfig).not.toHaveBeenCalled();
+      expect(getLlmModels).not.toHaveBeenCalled();
+    });
+    await user.click(screen.getByRole('button', { name: '工具' }));
+    await waitFor(() => expect(listClusters).toHaveBeenCalledTimes(1));
+    await waitFor(() => expect(listTools).toHaveBeenCalledWith('cluster-a'));
+    expect(screen.queryByText('Failed to load AI 
configuration')).not.toBeInTheDocument();
+    expect(screen.queryByText('加载 AI 配置失败')).not.toBeInTheDocument();
+  });
+
   it('uses the mode carried from the home-page draft', async () => {
     vi.mocked(chatStream).mockResolvedValue(undefined);
     renderPage({ prompt: '检查集群状态', mode: 'diagnose' });
@@ -179,7 +197,9 @@ describe('AiPage tool runner', () => {
     
expect(useAiChatHistoryStore.getState().histories.real.conversations).toEqual(
       expect.arrayContaining([
         expect.objectContaining({
-          messages: expect.arrayContaining([expect.objectContaining({ text: 
'Previous conversation' })]),
+          messages: expect.arrayContaining([
+            expect.objectContaining({ text: 'Previous conversation' }),
+          ]),
         }),
       ]),
     );
@@ -237,7 +257,9 @@ describe('AiPage tool runner', () => {
     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();
+    expect(
+      screen.getByRole('button', { name: /^Previous conversation5 分钟前$/ }),
+    ).toBeInTheDocument();
     await user.click(screen.getByRole('button', { name: /^Previous 
conversation/ }));
 
     expect(
@@ -285,7 +307,9 @@ 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());
+    await waitFor(() =>
+      expect(screen.queryByRole('button', { name: '停止' 
})).not.toBeInTheDocument(),
+    );
   });
 
   it('does not send while an input method composition is being confirmed', 
async () => {
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 3e6c3f577..536afc892 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 useAuthStore from '../../stores/authStore';
 import {
   getRecentAiChatConversations,
   flushAiChatHistoryPersistence,
@@ -189,7 +190,12 @@ const UserBubble = ({ text, createdAt }: Pick<Message, 
'text' | 'createdAt'>) =>
         {text}
         {createdAt && (
           <div
-            style={{ marginTop: 4, color: token.colorTextTertiary, fontSize: 
14, textAlign: 'right' }}
+            style={{
+              marginTop: 4,
+              color: token.colorTextTertiary,
+              fontSize: 14,
+              textAlign: 'right',
+            }}
           >
             {formatTimeOfDay(createdAt)}
           </div>
@@ -203,216 +209,216 @@ export const AiMessage = ({ msg }: { msg: Message }) => 
{
   const { token } = theme.useToken();
 
   return (
-  <Flex gap={12} align="flex-start" style={{ marginBottom: 16 }}>
-    <div
-      style={{
-        width: 36,
-        height: 36,
-        borderRadius: '50%',
-        background: `linear-gradient(135deg, ${token.colorPrimary} 0%, 
${token.colorPrimaryHover} 100%)`,
-        flexShrink: 0,
-        display: 'flex',
-        alignItems: 'center',
-        justifyContent: 'center',
-        boxShadow: '0 2px 8px rgba(22, 119, 255, 0.3)',
-      }}
-    >
-      <svg
-        width="20"
-        height="20"
-        viewBox="0 0 24 24"
-        fill="none"
-        stroke="white"
-        strokeWidth="2"
-        strokeLinecap="round"
-        strokeLinejoin="round"
+    <Flex gap={12} align="flex-start" style={{ marginBottom: 16 }}>
+      <div
+        style={{
+          width: 36,
+          height: 36,
+          borderRadius: '50%',
+          background: `linear-gradient(135deg, ${token.colorPrimary} 0%, 
${token.colorPrimaryHover} 100%)`,
+          flexShrink: 0,
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+          boxShadow: '0 2px 8px rgba(22, 119, 255, 0.3)',
+        }}
       >
-        <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 
5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
-        <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 
2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
-        <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
-        <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
-      </svg>
-    </div>
-    <Card
-      size="small"
-      style={{
-        maxWidth: '75%',
-        background: token.colorBgElevated,
-        borderColor: token.colorBorderSecondary,
-        boxShadow: `0 1px 4px ${token.colorTextQuaternary}`,
-        borderRadius: 12,
-        borderTopLeftRadius: 4,
-      }}
-      styles={{ body: { padding: '12px 16px' } }}
-    >
-      {/* Tool call indicator */}
-      {msg.toolCall && (
-        <Tag
-          color="purple"
-          style={{
-            marginBottom: 12,
-            borderRadius: 6,
-            fontSize: 14,
-            background: token.colorPrimaryBg,
-            borderColor: token.colorPrimaryBorder,
-          }}
+        <svg
+          width="20"
+          height="20"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="white"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
         >
-          {msg.toolCall.label}
-        </Tag>
-      )}
-
-      {/* Table content */}
-      {msg.tableData && msg.tableColumns && (
-        <Table
-          dataSource={msg.tableData}
-          columns={msg.tableColumns}
-          rowKey="key"
-          size="small"
-          pagination={false}
-          style={{ marginBottom: 12 }}
-        />
-      )}
-
-      {/* Stat cards */}
-      {msg.stats && (
-        <Row gutter={12} style={{ marginBottom: 12 }}>
-          {msg.stats.map((s) => (
-            <Col key={s.title}>
-              <Card
-                size="small"
-                style={{
-                  borderRadius: 8,
-                  borderTop: `3px solid ${s.color}`,
-                  minWidth: 120,
-                }}
-                styles={{ body: { padding: '8px 12px' } }}
-              >
-                <Statistic
-                  title={
-                    <Text type="secondary" style={{ fontSize: 14 }}>
-                      {s.title}
-                    </Text>
-                  }
-                  value={s.value}
-                  suffix={s.suffix}
-                  valueStyle={{ fontSize: 20, fontWeight: 600, color: s.color 
}}
-                />
-              </Card>
-            </Col>
-          ))}
-        </Row>
-      )}
-
-      {/* Descriptions */}
-      {msg.descriptions && (
-        <Descriptions bordered size="small" column={2} style={{ marginBottom: 
12 }}>
-          {msg.descriptions.map((d) => (
-            <Descriptions.Item key={d.label} label={d.label}>
-              <Text strong>{d.value}</Text>
-            </Descriptions.Item>
-          ))}
-        </Descriptions>
-      )}
-
-      {/* Chain of thought (enhanced prompt) */}
-      {msg.thinking && (
-        <details style={{ marginBottom: 12 }}>
-          <summary
-            style={{
-              cursor: 'pointer',
-              color: token.colorPrimary,
-              fontSize: 14,
-              fontWeight: 500,
-              userSelect: 'none',
-            }}
-          >
-            思维链:Prompt 增强改写
-          </summary>
-          <div
+          <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 
5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
+          <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 
2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
+          <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
+          <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
+        </svg>
+      </div>
+      <Card
+        size="small"
+        style={{
+          maxWidth: '75%',
+          background: token.colorBgElevated,
+          borderColor: token.colorBorderSecondary,
+          boxShadow: `0 1px 4px ${token.colorTextQuaternary}`,
+          borderRadius: 12,
+          borderTopLeftRadius: 4,
+        }}
+        styles={{ body: { padding: '12px 16px' } }}
+      >
+        {/* Tool call indicator */}
+        {msg.toolCall && (
+          <Tag
+            color="purple"
             style={{
-              marginTop: 8,
-              padding: '8px 12px',
-              background: token.colorFillSecondary,
-              border: `1px solid ${token.colorBorderSecondary}`,
-              borderRadius: 8,
+              marginBottom: 12,
+              borderRadius: 6,
               fontSize: 14,
-              lineHeight: 1.7,
-              color: token.colorTextSecondary,
-              whiteSpace: 'pre-wrap',
+              background: token.colorPrimaryBg,
+              borderColor: token.colorPrimaryBorder,
             }}
           >
-            {msg.thinking}
-          </div>
-        </details>
-      )}
+            {msg.toolCall.label}
+          </Tag>
+        )}
 
-      {/* Waiting indicator (inside the bubble) */}
-      {msg.pending && !msg.summary && (
-        <Flex gap={4} align="center" style={{ padding: '2px 0' }}>
-          <span
-            style={{
-              display: 'inline-block',
-              width: 6,
-              height: 6,
-              borderRadius: '50%',
-              background: token.colorPrimary,
-              animation: 'dotPulse 1.4s infinite ease-in-out',
-            }}
-          />
-          <span
-            style={{
-              display: 'inline-block',
-              width: 6,
-              height: 6,
-              borderRadius: '50%',
-              background: token.colorPrimary,
-              animation: 'dotPulse 1.4s infinite ease-in-out 0.2s',
-            }}
-          />
-          <span
-            style={{
-              display: 'inline-block',
-              width: 6,
-              height: 6,
-              borderRadius: '50%',
-              background: token.colorPrimary,
-              animation: 'dotPulse 1.4s infinite ease-in-out 0.4s',
-            }}
+        {/* Table content */}
+        {msg.tableData && msg.tableColumns && (
+          <Table
+            dataSource={msg.tableData}
+            columns={msg.tableColumns}
+            rowKey="key"
+            size="small"
+            pagination={false}
+            style={{ marginBottom: 12 }}
           />
-          <Text type="secondary" style={{ fontSize: 14, marginLeft: 8 }}>
-            正在思考…
-          </Text>
-        </Flex>
-      )}
+        )}
 
-      {/* Summary text */}
-      {msg.summary && (
-        <div className="ai-markdown">
-          <ReactMarkdown 
remarkPlugins={[remarkGfm]}>{msg.summary}</ReactMarkdown>
-        </div>
-      )}
-
-      {/* Action buttons */}
-      {msg.actions && (
-        <>
-          <Divider style={{ margin: '12px 0 8px' }} />
-          <Flex gap={8}>
-            {msg.actions.map((a) => (
-              <Button key={a.label} type={a.type || 'default'} size="small">
-                {a.label}
-              </Button>
+        {/* Stat cards */}
+        {msg.stats && (
+          <Row gutter={12} style={{ marginBottom: 12 }}>
+            {msg.stats.map((s) => (
+              <Col key={s.title}>
+                <Card
+                  size="small"
+                  style={{
+                    borderRadius: 8,
+                    borderTop: `3px solid ${s.color}`,
+                    minWidth: 120,
+                  }}
+                  styles={{ body: { padding: '8px 12px' } }}
+                >
+                  <Statistic
+                    title={
+                      <Text type="secondary" style={{ fontSize: 14 }}>
+                        {s.title}
+                      </Text>
+                    }
+                    value={s.value}
+                    suffix={s.suffix}
+                    valueStyle={{ fontSize: 20, fontWeight: 600, color: 
s.color }}
+                  />
+                </Card>
+              </Col>
+            ))}
+          </Row>
+        )}
+
+        {/* Descriptions */}
+        {msg.descriptions && (
+          <Descriptions bordered size="small" column={2} style={{ 
marginBottom: 12 }}>
+            {msg.descriptions.map((d) => (
+              <Descriptions.Item key={d.label} label={d.label}>
+                <Text strong>{d.value}</Text>
+              </Descriptions.Item>
             ))}
+          </Descriptions>
+        )}
+
+        {/* Chain of thought (enhanced prompt) */}
+        {msg.thinking && (
+          <details style={{ marginBottom: 12 }}>
+            <summary
+              style={{
+                cursor: 'pointer',
+                color: token.colorPrimary,
+                fontSize: 14,
+                fontWeight: 500,
+                userSelect: 'none',
+              }}
+            >
+              思维链:Prompt 增强改写
+            </summary>
+            <div
+              style={{
+                marginTop: 8,
+                padding: '8px 12px',
+                background: token.colorFillSecondary,
+                border: `1px solid ${token.colorBorderSecondary}`,
+                borderRadius: 8,
+                fontSize: 14,
+                lineHeight: 1.7,
+                color: token.colorTextSecondary,
+                whiteSpace: 'pre-wrap',
+              }}
+            >
+              {msg.thinking}
+            </div>
+          </details>
+        )}
+
+        {/* Waiting indicator (inside the bubble) */}
+        {msg.pending && !msg.summary && (
+          <Flex gap={4} align="center" style={{ padding: '2px 0' }}>
+            <span
+              style={{
+                display: 'inline-block',
+                width: 6,
+                height: 6,
+                borderRadius: '50%',
+                background: token.colorPrimary,
+                animation: 'dotPulse 1.4s infinite ease-in-out',
+              }}
+            />
+            <span
+              style={{
+                display: 'inline-block',
+                width: 6,
+                height: 6,
+                borderRadius: '50%',
+                background: token.colorPrimary,
+                animation: 'dotPulse 1.4s infinite ease-in-out 0.2s',
+              }}
+            />
+            <span
+              style={{
+                display: 'inline-block',
+                width: 6,
+                height: 6,
+                borderRadius: '50%',
+                background: token.colorPrimary,
+                animation: 'dotPulse 1.4s infinite ease-in-out 0.4s',
+              }}
+            />
+            <Text type="secondary" style={{ fontSize: 14, marginLeft: 8 }}>
+              正在思考…
+            </Text>
           </Flex>
-        </>
-      )}
+        )}
 
-      {msg.createdAt && (
-        <div style={{ marginTop: 8, color: token.colorTextTertiary, fontSize: 
14 }}>
-          {formatTimeOfDay(msg.createdAt)}
-        </div>
-      )}
-    </Card>
-  </Flex>
+        {/* Summary text */}
+        {msg.summary && (
+          <div className="ai-markdown">
+            <ReactMarkdown 
remarkPlugins={[remarkGfm]}>{msg.summary}</ReactMarkdown>
+          </div>
+        )}
+
+        {/* Action buttons */}
+        {msg.actions && (
+          <>
+            <Divider style={{ margin: '12px 0 8px' }} />
+            <Flex gap={8}>
+              {msg.actions.map((a) => (
+                <Button key={a.label} type={a.type || 'default'} size="small">
+                  {a.label}
+                </Button>
+              ))}
+            </Flex>
+          </>
+        )}
+
+        {msg.createdAt && (
+          <div style={{ marginTop: 8, color: token.colorTextTertiary, 
fontSize: 14 }}>
+            {formatTimeOfDay(msg.createdAt)}
+          </div>
+        )}
+      </Card>
+    </Flex>
   );
 };
 
@@ -425,6 +431,8 @@ const AiPage = () => {
   const location = useLocation();
   const navigate = useNavigate();
   const useMock = useDataModeStore((state) => state.useMock);
+  const userId = useAuthStore((state) => state.userId);
+  const admin = useAuthStore((state) => state.admin);
   const chatMode: AiChatDataMode = useMock ? 'mock' : 'real';
   const { token } = theme.useToken();
   const history = useAiChatHistoryStore((state) => state.histories[chatMode]);
@@ -471,6 +479,7 @@ const AiPage = () => {
     mode?: ChatMode;
     enhance?: boolean;
   } | null>(null);
+  const canInspectLlmRuntime = !userId || admin === true;
 
   const scrollToBottom = useCallback(() => {
     chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -488,11 +497,12 @@ const AiPage = () => {
       setLoading(false);
       previousChatModeRef.current = chatMode;
     }
-    conversationIdRef.current = 
useAiChatHistoryStore.getState().histories[chatMode].activeConversationId;
+    conversationIdRef.current =
+      
useAiChatHistoryStore.getState().histories[chatMode].activeConversationId;
   }, [chatMode, history.activeConversationId]);
 
   const loadLlmRuntime = useCallback(async () => {
-    if (useMock) {
+    if (useMock || !canInspectLlmRuntime) {
       setLlmConfig(null);
       setModelOptions([]);
       setSelectedModel('');
@@ -523,7 +533,7 @@ const AiPage = () => {
     } finally {
       setModelsLoading(false);
     }
-  }, [t, useMock]);
+  }, [canInspectLlmRuntime, t, useMock]);
 
   useEffect(() => {
     void Promise.resolve().then(loadLlmRuntime);
@@ -664,7 +674,9 @@ const AiPage = () => {
         if (controller.signal.aborted) {
           updateMessages(chatMode, conversationId, (prev) =>
             prev.map((item) =>
-              item.id === responseId && !item.summary ? { ...item, summary: 
t('ai.responseStopped') } : item,
+              item.id === responseId && !item.summary
+                ? { ...item, summary: t('ai.responseStopped') }
+                : item,
             ),
           );
         } else {
@@ -843,24 +855,26 @@ const AiPage = () => {
     <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}
+      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
@@ -883,7 +897,11 @@ const AiPage = () => {
           ) : (
             <AiMessage
               key={msg.id}
-              msg={msg.createdAt || !legacyMessageTimestamp ? msg : { ...msg, 
createdAt: legacyMessageTimestamp }}
+              msg={
+                msg.createdAt || !legacyMessageTimestamp
+                  ? msg
+                  : { ...msg, createdAt: legacyMessageTimestamp }
+              }
             />
           ),
         )}
@@ -967,6 +985,7 @@ const AiPage = () => {
                 onChange={(val) => setSelectedModel(val)}
                 options={modelOptions}
                 loading={modelsLoading}
+                disabled={!canInspectLlmRuntime}
                 variant="borderless"
                 placeholder={modelsLoading ? '加载模型中...' : '选择模型'}
                 popupMatchSelectWidth={false}
@@ -1085,7 +1104,15 @@ const AiPage = () => {
                 }`}
               >
                 <span style={{ display: 'flex', alignItems: 'center', gap: 12, 
minWidth: 0 }}>
-                  <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', 
textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+                  <span
+                    style={{
+                      flex: 1,
+                      minWidth: 0,
+                      overflow: 'hidden',
+                      textOverflow: 'ellipsis',
+                      whiteSpace: 'nowrap',
+                    }}
+                  >
                     {conversation.prompt}
                   </span>
                   <span

Reply via email to