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