From 3fc121c6b73602a654d3bf9670123af0725f6b5c Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 23 May 2026 22:54:57 +0800 Subject: [PATCH] fix(tool): show runtime names in tools admin --- .../main/java/vip/mate/tool/ToolRegistry.java | 47 ++++++++++-- .../java/vip/mate/tool/model/ToolEntity.java | 9 +++ .../vip/mate/tool/service/ToolService.java | 62 +++++++++++++-- .../service/ToolServiceRuntimeNamesTest.java | 76 +++++++++++++++++++ mateclaw-ui/src/types/index.ts | 2 + mateclaw-ui/src/views/Tools.vue | 15 +++- 6 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/service/ToolServiceRuntimeNamesTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java index 79c89fc8..67532885 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java @@ -86,16 +86,28 @@ public class ToolRegistry { * which {@link AgentToolSet} relies on (built-in tools first, MCP tools second). */ private LinkedHashMap getEnabledToolBeansByName() { + return getToolBeansByName(true); + } + + /** + * Iterate Spring beans once, returning a {@code beanName → bean} map of + * every @Tool bean. When {@code enabledOnly} is true, DB rows with + * {@code enabled=false} are excluded; when false, disabled rows are kept for + * admin metadata use cases where the UI still needs to resolve aliases. + */ + private LinkedHashMap getToolBeansByName(boolean enabledOnly) { // 1. 从数据库获取明确禁用的 beanName 黑名单 // 逻辑:只有 DB 中存在记录且 enabled=false 的才跳过 // DB 中没有记录的 bean 默认启用(向后兼容 + 新工具自动可用) - Set disabledBeanNames = toolMapper.selectList( - new LambdaQueryWrapper() - .eq(ToolEntity::getEnabled, false) - .isNotNull(ToolEntity::getBeanName) - ).stream() - .map(ToolEntity::getBeanName) - .collect(Collectors.toSet()); + Set disabledBeanNames = enabledOnly + ? toolMapper.selectList( + new LambdaQueryWrapper() + .eq(ToolEntity::getEnabled, false) + .isNotNull(ToolEntity::getBeanName) + ).stream() + .map(ToolEntity::getBeanName) + .collect(Collectors.toSet()) + : Set.of(); LinkedHashMap enabled = new LinkedHashMap<>(); @@ -120,10 +132,29 @@ public class ToolRegistry { } } - log.info("Total enabled tools: {}", enabled.size()); + log.info("Total {} tools: {}", enabledOnly ? "enabled" : "registered", enabled.size()); return enabled; } + /** + * Build an alias index for every registered {@code @Tool} bean, including + * rows disabled in DB. This is for admin display only; runtime tool + * exposure must continue to use {@link #getEnabledToolSet()}. + */ + public AgentToolSet getAllToolBeanSetForAdmin() { + LinkedHashMap beansByName = getToolBeansByName(false); + List toolBeans = new ArrayList<>(beansByName.values()); + IdentityHashMap nameByBean = new IdentityHashMap<>(); + for (Map.Entry e : beansByName.entrySet()) { + nameByBean.put(e.getValue(), e.getKey()); + } + List callbacks = new ArrayList<>(); + for (Object bean : toolBeans) { + Collections.addAll(callbacks, ToolCallbacks.from(bean)); + } + return AgentToolSet.fromCallbacks(toolBeans, callbacks, nameByBean::get); + } + /** * 获取统一的 AgentToolSet(包含 @Tool Bean + ToolCallbackProvider) *

diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java index 9b3251f5..73a5b1da 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; /** * 工具实体 @@ -68,6 +69,14 @@ public class ToolEntity { */ private String disclosureTier; + /** + * Runtime {@code @Tool} function names exposed by this row's bean/class + * aliases. Not persisted; populated for admin UI so tier changes can be + * correlated with the names the model actually sees. + */ + @TableField(exist = false) + private List runtimeNames; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java b/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java index 544bac8f..fc52751d 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java @@ -9,6 +9,8 @@ import vip.mate.tool.model.ToolEntity; import vip.mate.tool.repository.ToolMapper; import java.util.List; +import java.util.LinkedHashSet; +import java.util.Set; /** * 工具业务服务 @@ -24,11 +26,11 @@ public class ToolService { private final ToolRegistry toolRegistry; public List listTools() { - return toolRegistry.listToolEntities(); + return enrichRuntimeNames(toolRegistry.listToolEntities()); } public List listEnabledTools() { - return toolRegistry.listEnabledToolEntities(); + return enrichRuntimeNames(toolRegistry.listEnabledToolEntities()); } public ToolEntity getTool(Long id) { @@ -36,7 +38,7 @@ public class ToolService { if (tool == null) { throw new MateClawException("err.tool.not_found", "工具不存在: " + id); } - return tool; + return enrichRuntimeNames(tool); } public ToolEntity createTool(ToolEntity tool) { @@ -45,7 +47,7 @@ public class ToolService { tool.setEnabled(true); } toolMapper.insert(tool); - return tool; + return enrichRuntimeNames(tool); } public ToolEntity updateTool(ToolEntity tool) { @@ -53,10 +55,10 @@ public class ToolService { if (Boolean.TRUE.equals(existing.getBuiltin())) { existing.setEnabled(tool.getEnabled()); toolMapper.updateById(existing); - return existing; + return enrichRuntimeNames(existing); } toolMapper.updateById(tool); - return tool; + return enrichRuntimeNames(tool); } public void deleteTool(Long id) { @@ -71,7 +73,7 @@ public class ToolService { ToolEntity tool = getTool(id); tool.setEnabled(enabled); toolMapper.updateById(tool); - return tool; + return enrichRuntimeNames(tool); } /** @@ -83,6 +85,52 @@ public class ToolService { ToolEntity tool = getTool(id); tool.setDisclosureTier(vip.mate.tool.disclosure.DisclosureTier.fromToken(tier).token()); toolMapper.updateById(tool); + return enrichRuntimeNames(tool); + } + + private List enrichRuntimeNames(List tools) { + if (tools == null || tools.isEmpty()) { + return tools; + } + vip.mate.agent.AgentToolSet set; + try { + set = toolRegistry.getAllToolBeanSetForAdmin(); + } catch (Exception e) { + log.debug("Unable to enrich tool runtime names: {}", e.getMessage()); + return tools; + } + for (ToolEntity tool : tools) { + enrichRuntimeNames(tool, set); + } + return tools; + } + + private ToolEntity enrichRuntimeNames(ToolEntity tool) { + if (tool == null) { + return null; + } + try { + enrichRuntimeNames(tool, toolRegistry.getAllToolBeanSetForAdmin()); + } catch (Exception e) { + log.debug("Unable to enrich tool runtime names for {}: {}", tool.getName(), e.getMessage()); + } return tool; } + + private static void enrichRuntimeNames(ToolEntity tool, vip.mate.agent.AgentToolSet set) { + if (tool == null || set == null) { + return; + } + Set aliases = new LinkedHashSet<>(); + if (tool.getName() != null && !tool.getName().isBlank()) { + aliases.add(tool.getName()); + } + if (tool.getBeanName() != null && !tool.getBeanName().isBlank()) { + aliases.add(tool.getBeanName()); + } + Set names = set.functionNamesFor(aliases); + if (!names.isEmpty()) { + tool.setRuntimeNames(List.copyOf(names)); + } + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/service/ToolServiceRuntimeNamesTest.java b/mateclaw-server/src/test/java/vip/mate/tool/service/ToolServiceRuntimeNamesTest.java new file mode 100644 index 00000000..e4e71d4f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/service/ToolServiceRuntimeNamesTest.java @@ -0,0 +1,76 @@ +package vip.mate.tool.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; +import vip.mate.agent.AgentToolSet; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.model.ToolEntity; +import vip.mate.tool.repository.ToolMapper; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ToolServiceRuntimeNamesTest { + + static class ImageGenerateTool { + @Tool(description = "text to image") + public String image_generate() { + return ""; + } + } + + @Test + @DisplayName("listTools enriches DB class/bean rows with runtime function names") + void listToolsEnrichesRuntimeNames() { + ToolMapper mapper = mock(ToolMapper.class); + ToolRegistry registry = mock(ToolRegistry.class); + ToolEntity row = new ToolEntity(); + row.setName("ImageGenerateTool"); + row.setBeanName("imageGenerateTool"); + + ImageGenerateTool bean = new ImageGenerateTool(); + AgentToolSet set = AgentToolSet.fromCallbacks( + List.of(bean), + List.of(ToolCallbacks.from(bean)), + Map.of(bean, "imageGenerateTool")::get); + when(registry.listToolEntities()).thenReturn(List.of(row)); + when(registry.getAllToolBeanSetForAdmin()).thenReturn(set); + + ToolService service = new ToolService(mapper, registry); + + ToolEntity result = service.listTools().get(0); + + assertEquals(List.of("image_generate"), result.getRuntimeNames()); + } + + @Test + @DisplayName("runtime names are still enriched for disabled tool rows") + void disabledRowsStillGetRuntimeNames() { + ToolMapper mapper = mock(ToolMapper.class); + ToolRegistry registry = mock(ToolRegistry.class); + ToolEntity row = new ToolEntity(); + row.setName("ImageGenerateTool"); + row.setBeanName("imageGenerateTool"); + row.setEnabled(false); + + ImageGenerateTool bean = new ImageGenerateTool(); + AgentToolSet adminSet = AgentToolSet.fromCallbacks( + List.of(bean), + List.of(ToolCallbacks.from(bean)), + Map.of(bean, "imageGenerateTool")::get); + when(registry.listToolEntities()).thenReturn(List.of(row)); + when(registry.getAllToolBeanSetForAdmin()).thenReturn(adminSet); + + ToolService service = new ToolService(mapper, registry); + + ToolEntity result = service.listTools().get(0); + + assertEquals(List.of("image_generate"), result.getRuntimeNames()); + } +} diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 34898c8c..1a14dde9 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -503,6 +503,8 @@ export interface Tool { description?: string beanName?: string toolType: string + /** Runtime @Tool function names shown to the model, when resolvable. */ + runtimeNames?: string[] icon?: string mcpEndpoint?: string paramsSchema?: string diff --git a/mateclaw-ui/src/views/Tools.vue b/mateclaw-ui/src/views/Tools.vue index 759d652b..d9cd30fa 100644 --- a/mateclaw-ui/src/views/Tools.vue +++ b/mateclaw-ui/src/views/Tools.vue @@ -43,9 +43,12 @@

-
{{ tool.name }}
+
{{ primaryToolName(tool) }}
{{ tool.description }}
- {{ tool.beanName }} +
+ {{ tool.name }} + {{ tool.beanName }} +
@@ -160,6 +163,11 @@ function effectiveTier(tool: Tool): Tier { return tool.disclosureTier === 'extension' ? 'extension' : 'core' } +function primaryToolName(tool: Tool): string { + const runtimeName = Array.isArray(tool.runtimeNames) ? tool.runtimeNames[0] : '' + return runtimeName || tool.name +} + // Only builtin / channel atomic tools are tiered on the row itself; MCP / ACP / // skill tools are tiered at their owning source, so their control is locked. function canEditTier(tool: Tool): boolean { @@ -285,8 +293,9 @@ async function toggleTool(tool: Tool) { .tool-icon-wrap { width: 36px; height: 36px; background: linear-gradient(135deg, rgba(217,109,87,0.12), rgba(24,74,69,0.08)); border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--mc-text-secondary); } .tool-name { font-weight: 500; color: var(--mc-text-primary); } .tool-desc { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; } +.tool-meta-line { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } .bean-name { background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 4px; font-size: 12px; color: var(--mc-text-primary); } -.tool-bean { display: inline-block; margin-top: 4px; padding: 1px 6px; background: var(--mc-bg-sunken); border-radius: 4px; font-size: 11px; color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); } +.tool-bean { display: inline-block; padding: 1px 6px; background: var(--mc-bg-sunken); border-radius: 4px; font-size: 11px; color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); } .type-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; } .type-builtin { background: var(--mc-primary-bg); color: var(--mc-primary); } .type-mcp { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }