fix(tool): show runtime names in tools admin

This commit is contained in:
matevip 2026-05-23 22:54:57 +08:00
parent 123d912f84
commit 3fc121c6b7
6 changed files with 193 additions and 18 deletions

View File

@ -86,16 +86,28 @@ public class ToolRegistry {
* which {@link AgentToolSet} relies on (built-in tools first, MCP tools second).
*/
private LinkedHashMap<String, Object> 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<String, Object> getToolBeansByName(boolean enabledOnly) {
// 1. 从数据库获取明确禁用的 beanName 黑名单
// 逻辑只有 DB 中存在记录且 enabled=false 的才跳过
// DB 中没有记录的 bean 默认启用向后兼容 + 新工具自动可用
Set<String> disabledBeanNames = toolMapper.selectList(
new LambdaQueryWrapper<ToolEntity>()
.eq(ToolEntity::getEnabled, false)
.isNotNull(ToolEntity::getBeanName)
).stream()
.map(ToolEntity::getBeanName)
.collect(Collectors.toSet());
Set<String> disabledBeanNames = enabledOnly
? toolMapper.selectList(
new LambdaQueryWrapper<ToolEntity>()
.eq(ToolEntity::getEnabled, false)
.isNotNull(ToolEntity::getBeanName)
).stream()
.map(ToolEntity::getBeanName)
.collect(Collectors.toSet())
: Set.of();
LinkedHashMap<String, Object> 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<String, Object> beansByName = getToolBeansByName(false);
List<Object> toolBeans = new ArrayList<>(beansByName.values());
IdentityHashMap<Object, String> nameByBean = new IdentityHashMap<>();
for (Map.Entry<String, Object> e : beansByName.entrySet()) {
nameByBean.put(e.getValue(), e.getKey());
}
List<ToolCallback> callbacks = new ArrayList<>();
for (Object bean : toolBeans) {
Collections.addAll(callbacks, ToolCallbacks.from(bean));
}
return AgentToolSet.fromCallbacks(toolBeans, callbacks, nameByBean::get);
}
/**
* 获取统一的 AgentToolSet包含 @Tool Bean + ToolCallbackProvider
* <p>

View File

@ -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<String> runtimeNames;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -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<ToolEntity> listTools() {
return toolRegistry.listToolEntities();
return enrichRuntimeNames(toolRegistry.listToolEntities());
}
public List<ToolEntity> 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<ToolEntity> enrichRuntimeNames(List<ToolEntity> 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<String> 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<String> names = set.functionNamesFor(aliases);
if (!names.isEmpty()) {
tool.setRuntimeNames(List.copyOf(names));
}
}
}

View File

@ -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());
}
}

View File

@ -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

View File

@ -43,9 +43,12 @@
</svg>
</div>
<div>
<div class="tool-name">{{ tool.name }}</div>
<div class="tool-name">{{ primaryToolName(tool) }}</div>
<div class="tool-desc">{{ tool.description }}</div>
<code class="tool-bean">{{ tool.beanName }}</code>
<div class="tool-meta-line">
<code v-if="tool.name && tool.name !== primaryToolName(tool)" class="tool-bean">{{ tool.name }}</code>
<code v-if="tool.beanName" class="tool-bean">{{ tool.beanName }}</code>
</div>
</div>
</div>
</td>
@ -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); }