mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): catalog sort + usage stats
This commit is contained in:
parent
68e1c6f50d
commit
3d50b9c132
@ -250,7 +250,8 @@ public class AgentGraphBuilder {
|
||||
entity.getId(), rawMaxIter, maxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
|
||||
}
|
||||
|
||||
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled);
|
||||
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled,
|
||||
boundTools, runtimeModel.getMaxInputTokens());
|
||||
|
||||
// 当前仅支持 DashScope 和 OpenAI-compatible,其他协议直接拒绝
|
||||
if (!supportsStateGraph(protocol)) {
|
||||
@ -901,7 +902,8 @@ public class AgentGraphBuilder {
|
||||
|
||||
// ==================== Prompt 构建 ====================
|
||||
|
||||
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
|
||||
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled,
|
||||
Set<String> boundTools, Integer maxInputTokens) {
|
||||
// 通过 MemoryManager 从所有 MemoryProvider 组装系统提示词(快照冻结)
|
||||
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId());
|
||||
String basePrompt = (memoryPrompt != null && !memoryPrompt.isBlank())
|
||||
@ -910,7 +912,8 @@ public class AgentGraphBuilder {
|
||||
|
||||
// 使用 skill runtime 构建技能增强(per-agent 绑定过滤)
|
||||
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
|
||||
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(boundSkillIds);
|
||||
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(
|
||||
boundSkillIds, boundTools, maxInputTokens, entity.getId());
|
||||
|
||||
// 工具调用指导
|
||||
String toolGuidance = """
|
||||
|
||||
@ -16,6 +16,8 @@ import vip.mate.skill.lessons.SkillLessonsService;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.SkillDependencyChecker;
|
||||
import vip.mate.skill.runtime.SkillCatalogSort;
|
||||
import vip.mate.skill.runtime.SkillCatalogSorter;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.synthesis.SkillSynthesisService;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
@ -64,66 +66,22 @@ public class SkillController {
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String skillType,
|
||||
@RequestParam(required = false) Boolean enabled,
|
||||
@RequestParam(required = false) String scanStatus) {
|
||||
IPage<SkillEntity> dbPage = skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus);
|
||||
boolean mergeMcpVirtuals = page == 1
|
||||
&& (skillType == null || skillType.isBlank() || "mcp".equalsIgnoreCase(skillType));
|
||||
boolean mergeAcpVirtuals = page == 1
|
||||
&& (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType));
|
||||
Set<String> realNames = (mergeMcpVirtuals || mergeAcpVirtuals) ? realSkillNames() : Set.of();
|
||||
|
||||
// RFC-090 §3.2 — surface MCP servers as virtual skills on the
|
||||
// first page so users see GitHub / Filesystem / etc. as cards
|
||||
// alongside built-in and uploaded skills. Restricted to page 1
|
||||
// because virtual rows are unpaginated; a follow-up can push
|
||||
// them through proper SQL UNION ALL when MCP server count gets
|
||||
// into the dozens.
|
||||
if (mergeMcpVirtuals) {
|
||||
try {
|
||||
List<SkillEntity> mcpSkills = mcpSkillBridge.listMcpDerivedSkillEntities();
|
||||
if (!mcpSkills.isEmpty()) {
|
||||
// In-memory filter mirrors the DB filters so the
|
||||
// user's filter chips still apply to virtual rows.
|
||||
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
|
||||
List<SkillEntity> filtered = filterShadowedVirtualSkills(mcpSkills, realNames).stream()
|
||||
.filter(s -> kw.isEmpty()
|
||||
|| (s.getName() != null && s.getName().toLowerCase().contains(kw))
|
||||
|| (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw)))
|
||||
.filter(s -> enabled == null || enabled.equals(s.getEnabled()))
|
||||
.toList();
|
||||
if (!filtered.isEmpty()) {
|
||||
java.util.List<SkillEntity> merged = new java.util.ArrayList<>(filtered);
|
||||
merged.addAll(dbPage.getRecords());
|
||||
dbPage.setRecords(merged);
|
||||
dbPage.setTotal(dbPage.getTotal() + filtered.size());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Bridge failure must not break the Skills page.
|
||||
}
|
||||
}
|
||||
// RFC-090 §3.2 (parallel) — same auto-bridge for ACP endpoints.
|
||||
if (mergeAcpVirtuals) {
|
||||
try {
|
||||
List<SkillEntity> acpSkills = acpSkillBridge.listAcpDerivedSkillEntities();
|
||||
if (!acpSkills.isEmpty()) {
|
||||
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
|
||||
List<SkillEntity> filtered = filterShadowedVirtualSkills(acpSkills, realNames).stream()
|
||||
.filter(s -> kw.isEmpty()
|
||||
|| (s.getName() != null && s.getName().toLowerCase().contains(kw))
|
||||
|| (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw)))
|
||||
.filter(s -> enabled == null || enabled.equals(s.getEnabled()))
|
||||
.toList();
|
||||
if (!filtered.isEmpty()) {
|
||||
java.util.List<SkillEntity> merged = new java.util.ArrayList<>(filtered);
|
||||
merged.addAll(dbPage.getRecords());
|
||||
dbPage.setRecords(merged);
|
||||
dbPage.setTotal(dbPage.getTotal() + filtered.size());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Bridge failure must not break the Skills page.
|
||||
}
|
||||
@RequestParam(required = false) String scanStatus,
|
||||
@RequestParam(required = false) String sort,
|
||||
@RequestParam(required = false) String source,
|
||||
@RequestParam(required = false) String runtime,
|
||||
@RequestParam(required = false) Long agentId) {
|
||||
Set<Long> pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of();
|
||||
if (pinnedSkillIds == null) pinnedSkillIds = Set.of();
|
||||
IPage<SkillEntity> dbPage = skillService.pageSkills(
|
||||
page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, pinnedSkillIds);
|
||||
List<SkillEntity> virtualSkills = visibleVirtualSkills(
|
||||
keyword, skillType, enabled, scanStatus, sort, source, runtime);
|
||||
if (!virtualSkills.isEmpty()) {
|
||||
VirtualPageMergeResult merged = mergeVirtualTailPageRecords(
|
||||
dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size);
|
||||
dbPage.setRecords(merged.records());
|
||||
dbPage.setTotal(merged.total());
|
||||
}
|
||||
return R.ok(dbPage);
|
||||
}
|
||||
@ -179,6 +137,109 @@ public class SkillController {
|
||||
return filterShadowedVirtualSkills(virtualSkills, realSkillNames).size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep MyBatis-Plus as the source of truth for DB pagination and append
|
||||
* live virtual ACP/MCP rows after the DB rows. This produces one stable
|
||||
* combined sequence:
|
||||
* <pre>
|
||||
* [all DB rows sorted by SQL] + [unshadowed live virtual rows]
|
||||
* </pre>
|
||||
*
|
||||
* The tail ordering avoids shifting every DB page whenever a runtime ACP
|
||||
* endpoint appears, and it keeps {@code total} consistent across all pages.
|
||||
*/
|
||||
static VirtualPageMergeResult mergeVirtualTailPageRecords(List<SkillEntity> dbRecords,
|
||||
List<SkillEntity> virtualSkills,
|
||||
long dbTotal,
|
||||
int page,
|
||||
int size) {
|
||||
List<SkillEntity> records = new ArrayList<>(dbRecords == null ? List.of() : dbRecords);
|
||||
List<SkillEntity> virtualRows = virtualSkills == null ? List.of() : virtualSkills;
|
||||
long normalizedDbTotal = Math.max(dbTotal, 0);
|
||||
long total = normalizedDbTotal + virtualRows.size();
|
||||
if (virtualRows.isEmpty()) {
|
||||
return new VirtualPageMergeResult(records, total);
|
||||
}
|
||||
|
||||
int safePage = Math.max(page, 1);
|
||||
int safeSize = Math.max(size, 1);
|
||||
long startInclusive = (long) (safePage - 1) * safeSize;
|
||||
long endExclusive = startInclusive + safeSize;
|
||||
if (endExclusive <= normalizedDbTotal || records.size() >= safeSize) {
|
||||
return new VirtualPageMergeResult(records, total);
|
||||
}
|
||||
|
||||
long virtualStart = Math.max(0, startInclusive - normalizedDbTotal);
|
||||
int remainingSlots = safeSize - records.size();
|
||||
for (long i = virtualStart; i < virtualRows.size() && remainingSlots > 0; i++) {
|
||||
records.add(virtualRows.get((int) i));
|
||||
remainingSlots--;
|
||||
}
|
||||
return new VirtualPageMergeResult(records, total);
|
||||
}
|
||||
|
||||
record VirtualPageMergeResult(List<SkillEntity> records, long total) {}
|
||||
|
||||
private List<SkillEntity> visibleVirtualSkills(String keyword,
|
||||
String skillType,
|
||||
Boolean enabled,
|
||||
String scanStatus,
|
||||
String sort,
|
||||
String source,
|
||||
String runtime) {
|
||||
String effectiveSource = source != null && !source.isBlank() ? source : skillType;
|
||||
boolean includeMcpVirtuals = isAllSkillType(effectiveSource) || "mcp".equalsIgnoreCase(effectiveSource);
|
||||
boolean includeAcpVirtuals = isAllSkillType(effectiveSource) || "acp".equalsIgnoreCase(effectiveSource);
|
||||
if (!includeMcpVirtuals && !includeAcpVirtuals) return List.of();
|
||||
|
||||
Set<String> realNames = realSkillNames();
|
||||
List<SkillEntity> result = new ArrayList<>();
|
||||
if (includeMcpVirtuals) {
|
||||
try {
|
||||
result.addAll(filterVirtualSkills(mcpSkillBridge.listMcpDerivedSkillEntities(),
|
||||
realNames, keyword, enabled, scanStatus, runtime));
|
||||
} catch (Exception ignored) {
|
||||
// Bridge failure must not break the Skills page.
|
||||
}
|
||||
}
|
||||
if (includeAcpVirtuals) {
|
||||
try {
|
||||
result.addAll(filterVirtualSkills(acpSkillBridge.listAcpDerivedSkillEntities(),
|
||||
realNames, keyword, enabled, scanStatus, runtime));
|
||||
} catch (Exception ignored) {
|
||||
// Bridge failure must not break the Skills page.
|
||||
}
|
||||
}
|
||||
return SkillCatalogSorter.sortEntities(result, SkillCatalogSort.parse(sort));
|
||||
}
|
||||
|
||||
private static List<SkillEntity> filterVirtualSkills(List<SkillEntity> virtualSkills,
|
||||
Set<String> realNames,
|
||||
String keyword,
|
||||
Boolean enabled,
|
||||
String scanStatus,
|
||||
String runtime) {
|
||||
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
|
||||
String normalizedScan = scanStatus == null ? "" : scanStatus.trim().toUpperCase();
|
||||
return filterShadowedVirtualSkills(virtualSkills, realNames).stream()
|
||||
.filter(s -> kw.isEmpty() || containsIgnoreCase(s.getName(), kw)
|
||||
|| containsIgnoreCase(s.getDescription(), kw)
|
||||
|| containsIgnoreCase(s.getTags(), kw))
|
||||
.filter(s -> enabled == null || enabled.equals(s.getEnabled()))
|
||||
.filter(s -> normalizedScan.isEmpty()
|
||||
|| normalizedScan.equalsIgnoreCase(s.getSecurityScanStatus()))
|
||||
.filter(s -> SkillCatalogSorter.runtimeMatches(s, runtime))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static boolean isAllSkillType(String skillType) {
|
||||
return skillType == null || skillType.isBlank() || "all".equalsIgnoreCase(skillType);
|
||||
}
|
||||
|
||||
private static boolean containsIgnoreCase(String value, String lowerCaseNeedle) {
|
||||
return value != null && value.toLowerCase().contains(lowerCaseNeedle);
|
||||
}
|
||||
|
||||
private Set<String> realSkillNames() {
|
||||
return skillService.listSkills().stream()
|
||||
.map(SkillEntity::getName)
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.skill.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.skill.usage.SkillUsageStatEntity;
|
||||
|
||||
@Mapper
|
||||
public interface SkillUsageStatMapper extends BaseMapper<SkillUsageStatEntity> {
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package vip.mate.skill.runtime;
|
||||
|
||||
public enum SkillCatalogSort {
|
||||
RECOMMENDED,
|
||||
NAME,
|
||||
TYPE,
|
||||
STATUS,
|
||||
UPDATED;
|
||||
|
||||
public static SkillCatalogSort parse(String value) {
|
||||
if (value == null || value.isBlank()) return RECOMMENDED;
|
||||
return switch (value.trim().toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "name" -> NAME;
|
||||
case "type", "source" -> TYPE;
|
||||
case "status", "runtime" -> STATUS;
|
||||
case "updated", "update_time", "updateTime" -> UPDATED;
|
||||
default -> RECOMMENDED;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
package vip.mate.skill.runtime;
|
||||
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
public final class SkillCatalogSorter {
|
||||
|
||||
private SkillCatalogSorter() {
|
||||
}
|
||||
|
||||
public static List<SkillEntity> sortEntities(List<SkillEntity> skills, SkillCatalogSort sort) {
|
||||
return sortEntities(skills, sort, Set.of());
|
||||
}
|
||||
|
||||
public static List<SkillEntity> sortEntities(List<SkillEntity> skills, SkillCatalogSort sort,
|
||||
Set<Long> pinnedSkillIds) {
|
||||
if (skills == null || skills.isEmpty()) return List.of();
|
||||
return skills.stream()
|
||||
.sorted(entityComparator(sort, pinnedSkillIds))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static List<ResolvedSkill> sortResolved(List<ResolvedSkill> skills, SkillCatalogSort sort) {
|
||||
if (skills == null || skills.isEmpty()) return List.of();
|
||||
return skills.stream()
|
||||
.sorted(resolvedComparator(sort))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static boolean sourceMatches(String actualType, String requestedSource) {
|
||||
if (requestedSource == null || requestedSource.isBlank()
|
||||
|| "all".equalsIgnoreCase(requestedSource)) {
|
||||
return true;
|
||||
}
|
||||
return normalizeType(actualType).equals(normalizeType(requestedSource));
|
||||
}
|
||||
|
||||
public static boolean sourceMatches(ResolvedSkill skill, String requestedSource) {
|
||||
if (requestedSource == null || requestedSource.isBlank()
|
||||
|| "all".equalsIgnoreCase(requestedSource)) {
|
||||
return true;
|
||||
}
|
||||
String requested = normalizeType(requestedSource);
|
||||
if ("builtin".equals(requested)) return skill.isBuiltin();
|
||||
if ("dynamic".equals(requested) && !skill.isBuiltin()) {
|
||||
String source = normalizeType(skill.getSource());
|
||||
return "database".equals(source) || "directory".equals(source);
|
||||
}
|
||||
return normalizeType(skill.getSource()).equals(requested);
|
||||
}
|
||||
|
||||
public static boolean runtimeMatches(SkillEntity skill, String requestedRuntime) {
|
||||
if (requestedRuntime == null || requestedRuntime.isBlank()
|
||||
|| "all".equalsIgnoreCase(requestedRuntime)) {
|
||||
return true;
|
||||
}
|
||||
String requested = requestedRuntime.trim().toLowerCase(Locale.ROOT);
|
||||
if ("disabled".equals(requested)) {
|
||||
return !Boolean.TRUE.equals(skill.getEnabled());
|
||||
}
|
||||
if ("blocked".equals(requested) || "security_blocked".equals(requested)) {
|
||||
return "FAILED".equalsIgnoreCase(skill.getSecurityScanStatus());
|
||||
}
|
||||
if ("ready".equals(requested)) {
|
||||
return Boolean.TRUE.equals(skill.getEnabled())
|
||||
&& !"FAILED".equalsIgnoreCase(skill.getSecurityScanStatus());
|
||||
}
|
||||
if ("setup_needed".equals(requested) || "setup-needed".equals(requested)) {
|
||||
String tags = skill.getTags();
|
||||
return tags != null && tags.toLowerCase(Locale.ROOT).contains("setup");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean runtimeMatches(ResolvedSkill skill, String requestedRuntime) {
|
||||
if (requestedRuntime == null || requestedRuntime.isBlank()
|
||||
|| "all".equalsIgnoreCase(requestedRuntime)) {
|
||||
return true;
|
||||
}
|
||||
String requested = requestedRuntime.trim().toLowerCase(Locale.ROOT);
|
||||
if ("disabled".equals(requested)) return !skill.isEnabled();
|
||||
if ("blocked".equals(requested) || "security_blocked".equals(requested)) return skill.isSecurityBlocked();
|
||||
if ("ready".equals(requested)) return SkillRuntimeService.passesActiveGate(skill);
|
||||
if ("setup_needed".equals(requested) || "setup-needed".equals(requested)) {
|
||||
return !skill.isDependencyReady() || "Dependencies Missing".equals(skill.getRuntimeStatusLabel());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static int sourceRank(String type) {
|
||||
return switch (normalizeType(type)) {
|
||||
case "builtin" -> 0;
|
||||
case "dynamic", "custom" -> 1;
|
||||
case "mcp" -> 2;
|
||||
case "acp" -> 3;
|
||||
default -> 4;
|
||||
};
|
||||
}
|
||||
|
||||
static Comparator<SkillEntity> entityComparator(SkillCatalogSort sort) {
|
||||
return entityComparator(sort, Set.of());
|
||||
}
|
||||
|
||||
static Comparator<SkillEntity> entityComparator(SkillCatalogSort sort, Set<Long> pinnedSkillIds) {
|
||||
SkillCatalogSort normalized = sort == null ? SkillCatalogSort.RECOMMENDED : sort;
|
||||
Set<Long> pinned = pinnedSkillIds == null ? Set.of() : pinnedSkillIds;
|
||||
return switch (normalized) {
|
||||
case NAME -> Comparator
|
||||
.comparing(SkillCatalogSorter::entityDisplayName, String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(s -> nullSafe(s.getName()), String.CASE_INSENSITIVE_ORDER);
|
||||
case TYPE -> Comparator
|
||||
.comparingInt((SkillEntity s) -> sourceRank(s.getSkillType()))
|
||||
.thenComparing(SkillCatalogSorter::entityDisplayName, String.CASE_INSENSITIVE_ORDER);
|
||||
case STATUS -> Comparator
|
||||
.comparingInt(SkillCatalogSorter::entityStatusRank)
|
||||
.thenComparingInt(s -> sourceRank(s.getSkillType()))
|
||||
.thenComparing(SkillCatalogSorter::entityDisplayName, String.CASE_INSENSITIVE_ORDER);
|
||||
case UPDATED -> Comparator
|
||||
.comparing((SkillEntity s) -> s.getUpdateTime(), Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(SkillCatalogSorter::entityDisplayName, String.CASE_INSENSITIVE_ORDER);
|
||||
case RECOMMENDED -> Comparator
|
||||
.comparingInt((SkillEntity s) -> s.getId() != null && pinned.contains(s.getId()) ? 0 : 1)
|
||||
.thenComparingInt(SkillCatalogSorter::entityStatusRank)
|
||||
.thenComparingInt(s -> sourceRank(s.getSkillType()))
|
||||
.thenComparing(SkillCatalogSorter::entityDisplayName, String.CASE_INSENSITIVE_ORDER);
|
||||
};
|
||||
}
|
||||
|
||||
static Comparator<ResolvedSkill> resolvedComparator(SkillCatalogSort sort) {
|
||||
SkillCatalogSort normalized = sort == null ? SkillCatalogSort.RECOMMENDED : sort;
|
||||
return switch (normalized) {
|
||||
case NAME -> Comparator.comparing(s -> nullSafe(s.getName()), String.CASE_INSENSITIVE_ORDER);
|
||||
case TYPE -> Comparator
|
||||
.comparingInt((ResolvedSkill s) -> resolvedSourceRank(s))
|
||||
.thenComparing(s -> nullSafe(s.getName()), String.CASE_INSENSITIVE_ORDER);
|
||||
case STATUS -> Comparator
|
||||
.comparingInt(SkillCatalogSorter::resolvedStatusRank)
|
||||
.thenComparingInt(SkillCatalogSorter::resolvedSourceRank)
|
||||
.thenComparing(s -> nullSafe(s.getName()), String.CASE_INSENSITIVE_ORDER);
|
||||
case UPDATED, RECOMMENDED -> Comparator
|
||||
.comparingInt(SkillCatalogSorter::resolvedStatusRank)
|
||||
.thenComparingInt(SkillCatalogSorter::resolvedSourceRank)
|
||||
.thenComparing(s -> nullSafe(s.getName()), String.CASE_INSENSITIVE_ORDER);
|
||||
};
|
||||
}
|
||||
|
||||
static int entityStatusRank(SkillEntity skill) {
|
||||
if ("FAILED".equalsIgnoreCase(skill.getSecurityScanStatus())) return 5;
|
||||
if (!Boolean.TRUE.equals(skill.getEnabled())) return 4;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int resolvedStatusRank(ResolvedSkill skill) {
|
||||
if (skill.isSecurityBlocked()) return 5;
|
||||
if (!skill.isEnabled()) return 4;
|
||||
if (!SkillRuntimeService.passesActiveGate(skill)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int resolvedSourceRank(ResolvedSkill skill) {
|
||||
if (skill.isBuiltin()) return 0;
|
||||
String source = normalizeType(skill.getSource());
|
||||
if ("database".equals(source) || "directory".equals(source)) return 1;
|
||||
return sourceRank(source);
|
||||
}
|
||||
|
||||
private static String entityDisplayName(SkillEntity skill) {
|
||||
if (skill.getNameZh() != null && !skill.getNameZh().isBlank()) return skill.getNameZh();
|
||||
if (skill.getNameEn() != null && !skill.getNameEn().isBlank()) return skill.getNameEn();
|
||||
return nullSafe(skill.getName());
|
||||
}
|
||||
|
||||
private static String normalizeType(String type) {
|
||||
if (type == null || type.isBlank()) return "";
|
||||
String normalized = type.trim().toLowerCase(Locale.ROOT);
|
||||
if ("custom".equals(normalized)) return "dynamic";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String nullSafe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import vip.mate.skill.mcp.McpSkillBridge;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.usage.SkillUsageService;
|
||||
|
||||
import vip.mate.skill.workspace.SkillWorkspaceEvent;
|
||||
|
||||
@ -21,6 +22,7 @@ import jakarta.annotation.PreDestroy;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
@ -59,18 +61,21 @@ public class SkillRuntimeService {
|
||||
* Same {@code @Lazy} treatment as the MCP bridge.
|
||||
*/
|
||||
private final AcpSkillBridge acpSkillBridge;
|
||||
private final SkillUsageService usageService;
|
||||
|
||||
@Autowired
|
||||
public SkillRuntimeService(SkillService skillService,
|
||||
SkillPackageResolver packageResolver,
|
||||
@Lazy SkillLessonsService lessonsService,
|
||||
@Lazy McpSkillBridge mcpSkillBridge,
|
||||
@Lazy AcpSkillBridge acpSkillBridge) {
|
||||
@Lazy AcpSkillBridge acpSkillBridge,
|
||||
@Lazy SkillUsageService usageService) {
|
||||
this.skillService = skillService;
|
||||
this.packageResolver = packageResolver;
|
||||
this.lessonsService = lessonsService;
|
||||
this.mcpSkillBridge = mcpSkillBridge;
|
||||
this.acpSkillBridge = acpSkillBridge;
|
||||
this.usageService = usageService;
|
||||
}
|
||||
|
||||
// 缓存已解析的 active skills(5分钟过期)
|
||||
@ -327,6 +332,26 @@ public class SkillRuntimeService {
|
||||
* 非 null 时仅包含指定 ID 的 skill。
|
||||
*/
|
||||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds) {
|
||||
return buildSkillPromptEnhancement(boundSkillIds, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建技能目录提示片段。
|
||||
*
|
||||
* @param boundSkillIds Agent 绑定的 skill ID 集合。null 表示使用全局默认(无绑定)。
|
||||
* @param effectiveToolNames 当前 agent 可见的工具名集合。null 表示不按 agent 绑定限制过滤。
|
||||
* @param maxInputTokens 当前模型最大输入窗口,用于控制目录大小。
|
||||
*/
|
||||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
|
||||
Set<String> effectiveToolNames,
|
||||
Integer maxInputTokens) {
|
||||
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null);
|
||||
}
|
||||
|
||||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
|
||||
Set<String> effectiveToolNames,
|
||||
Integer maxInputTokens,
|
||||
Long agentId) {
|
||||
List<ResolvedSkill> activeSkills;
|
||||
if (boundSkillIds != null) {
|
||||
// Per-agent 过滤:从全局 enabled skills 中按 ID 过滤。RFC-090
|
||||
@ -356,35 +381,52 @@ public class SkillRuntimeService {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Compact preamble — was ~1 KB of warnings before. The two failure
|
||||
// modes that drove the older wording (#46: LLM treats skill name
|
||||
// as a tool; #49: LLM invokes runSkillScript on a docs-only skill)
|
||||
// are now addressed in two cheaper spots:
|
||||
// - readSkillFile/runSkillScript tools have explicit "Tool not
|
||||
// found" errors that nudge the model to retry the right way.
|
||||
// - SKILL.md itself, once loaded via readSkillFile, tells the
|
||||
// model whether to invoke a script or just follow the prose.
|
||||
// 49 skills × ~20 chars/row saved by dropping the Shape column +
|
||||
// ~600 chars saved by trimming the preamble = ~1.5 KB / ~400
|
||||
// tokens lighter on every chat request.
|
||||
List<ResolvedSkill> visibleSkills = activeSkills.stream()
|
||||
.filter(s -> isVisibleWithTools(s, effectiveToolNames))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
if (visibleSkills.isEmpty()) return "";
|
||||
|
||||
Set<Long> boundIds = boundSkillIds == null ? Set.of() : boundSkillIds;
|
||||
int maxEntries = promptCatalogEntryLimit(maxInputTokens);
|
||||
int descLimit = promptDescriptionLimit(maxInputTokens);
|
||||
Set<String> recentNames = usageService.recentLoadedSkillNames(agentId, 8);
|
||||
Set<String> frequentNames = usageService.frequentlyLoadedSkillNames(8);
|
||||
List<ResolvedSkill> sorted = SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED)
|
||||
.stream()
|
||||
.sorted(java.util.Comparator
|
||||
.comparingInt((ResolvedSkill s) -> recentNames.contains(s.getName()) ? 0 : 1)
|
||||
.thenComparingInt(s -> frequentNames.contains(s.getName()) ? 0 : 1)
|
||||
.thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED)))
|
||||
.toList();
|
||||
List<ResolvedSkill> pinned = sorted.stream()
|
||||
.filter(s -> s.getId() != null && boundIds.contains(s.getId()))
|
||||
.toList();
|
||||
LinkedHashSet<ResolvedSkill> selected = new LinkedHashSet<>();
|
||||
selected.addAll(pinned);
|
||||
for (ResolvedSkill skill : sorted) {
|
||||
if (selected.size() >= Math.max(maxEntries, pinned.size())) break;
|
||||
selected.add(skill);
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n\n## Skills\n");
|
||||
sb.append("Before answering, scan the skills below. If a skill matches your task, ");
|
||||
sb.append("load it via `readSkillFile(skillName=<name>, filePath=\"SKILL.md\")` and follow its instructions. ");
|
||||
sb.append("This is a compact catalog. If a listed skill matches the task, ");
|
||||
sb.append("first call `readSkillFile(skillName=<name>, filePath=\"SKILL.md\")` and follow its instructions. ");
|
||||
sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog. ");
|
||||
sb.append("Skills are documentation packages — calling a skill name as a tool will fail. ");
|
||||
sb.append("Skills with a `scripts/` directory expose `runSkillScript`; SKILL.md will name the script when needed.\n\n");
|
||||
sb.append("| Skill | Description |\n");
|
||||
sb.append("|-------|-------------|\n");
|
||||
for (ResolvedSkill skill : activeSkills) {
|
||||
sb.append("| Skill | Status | Description |\n");
|
||||
sb.append("|-------|--------|-------------|\n");
|
||||
for (ResolvedSkill skill : selected) {
|
||||
sb.append("| `").append(skill.getName()).append("`");
|
||||
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
|
||||
sb.append(" ").append(skill.getIcon());
|
||||
}
|
||||
sb.append(" | ");
|
||||
sb.append(" | ").append(statusToken(skill)).append(" | ");
|
||||
if (skill.getDescription() != null && !skill.getDescription().isBlank()) {
|
||||
String desc = skill.getDescription();
|
||||
if (desc.length() > 200) {
|
||||
desc = desc.substring(0, 200) + "...";
|
||||
if (desc.length() > descLimit) {
|
||||
desc = desc.substring(0, descLimit) + "...";
|
||||
}
|
||||
// Escape pipe and newline so a multi-line description doesn't
|
||||
// break the table layout.
|
||||
@ -392,15 +434,49 @@ public class SkillRuntimeService {
|
||||
}
|
||||
sb.append(" |\n");
|
||||
}
|
||||
if (selected.size() < visibleSkills.size()) {
|
||||
sb.append("\nShowing ").append(selected.size()).append(" of ")
|
||||
.append(visibleSkills.size())
|
||||
.append(" available skills. Use `listAvailableSkills()` for the full catalog.\n");
|
||||
}
|
||||
|
||||
// RFC-090 §11.4.3 + §10.2 Q6 — append per-skill LESSONS.md after
|
||||
// the catalog so the LLM sees "Available Skills" first, then any
|
||||
// accumulated lessons attached to each skill that has opted in.
|
||||
appendLessonsBlock(sb, activeSkills);
|
||||
List<ResolvedSkill> lessonSkills = sorted.stream()
|
||||
.filter(s -> (s.getId() != null && boundIds.contains(s.getId())) || recentNames.contains(s.getName()))
|
||||
.toList();
|
||||
appendLessonsBlock(sb, lessonSkills);
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean isVisibleWithTools(ResolvedSkill skill, Set<String> effectiveToolNames) {
|
||||
if (effectiveToolNames == null) return true;
|
||||
Set<String> tools = skill.getEffectiveAllowedTools();
|
||||
return tools == null || tools.isEmpty() || effectiveToolNames.containsAll(tools);
|
||||
}
|
||||
|
||||
private static int promptCatalogEntryLimit(Integer maxInputTokens) {
|
||||
int max = maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 8192;
|
||||
if (max <= 8192) return 8;
|
||||
if (max <= 16384) return 12;
|
||||
if (max <= 32768) return 20;
|
||||
return 32;
|
||||
}
|
||||
|
||||
private static int promptDescriptionLimit(Integer maxInputTokens) {
|
||||
int max = maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 8192;
|
||||
if (max <= 8192) return 80;
|
||||
if (max <= 16384) return 100;
|
||||
if (max <= 32768) return 140;
|
||||
return 160;
|
||||
}
|
||||
|
||||
private static String statusToken(ResolvedSkill skill) {
|
||||
if (skill.isSecurityBlocked()) return "blocked";
|
||||
if (!skill.isEnabled()) return "disabled";
|
||||
if (!passesActiveGate(skill)) return "setup-needed";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a "## Lessons learned" block to the prompt enhancement
|
||||
* with one subsection per active skill that has lessons recorded.
|
||||
|
||||
@ -9,12 +9,15 @@ import org.springframework.stereotype.Service;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.runtime.SkillCatalogSort;
|
||||
import vip.mate.skill.runtime.SkillCatalogSorter;
|
||||
import vip.mate.skill.secret.SkillSecretService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceProperties;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -78,6 +81,27 @@ public class SkillService {
|
||||
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
|
||||
String skillType, Boolean enabled,
|
||||
String scanStatus) {
|
||||
return pageSkills(page, size, keyword, skillType, enabled, scanStatus,
|
||||
null, null, null, Set.of());
|
||||
}
|
||||
|
||||
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
|
||||
String skillType, Boolean enabled,
|
||||
String scanStatus,
|
||||
String sort,
|
||||
String source,
|
||||
String runtime) {
|
||||
return pageSkills(page, size, keyword, skillType, enabled, scanStatus,
|
||||
sort, source, runtime, Set.of());
|
||||
}
|
||||
|
||||
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
|
||||
String skillType, Boolean enabled,
|
||||
String scanStatus,
|
||||
String sort,
|
||||
String source,
|
||||
String runtime,
|
||||
Set<Long> pinnedSkillIds) {
|
||||
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
|
||||
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
@ -88,8 +112,12 @@ public class SkillService {
|
||||
.or().like(SkillEntity::getDescription, kw)
|
||||
.or().like(SkillEntity::getTags, kw));
|
||||
}
|
||||
if (skillType != null && !skillType.isBlank()) {
|
||||
wrapper.eq(SkillEntity::getSkillType, skillType);
|
||||
String effectiveSource = source != null && !source.isBlank() ? source : skillType;
|
||||
if (effectiveSource != null && !effectiveSource.isBlank()
|
||||
&& !"all".equalsIgnoreCase(effectiveSource)) {
|
||||
String normalizedSource = effectiveSource.trim().toLowerCase();
|
||||
if ("custom".equals(normalizedSource)) normalizedSource = "dynamic";
|
||||
wrapper.eq(SkillEntity::getSkillType, normalizedSource);
|
||||
}
|
||||
if (enabled != null) {
|
||||
wrapper.eq(SkillEntity::getEnabled, enabled);
|
||||
@ -98,14 +126,34 @@ public class SkillService {
|
||||
wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase());
|
||||
}
|
||||
|
||||
// Builtin first ('builtin' < 'dynamic' < 'mcp' alphabetically), then by
|
||||
// name for a stable order. Sorting on skill_type instead of the
|
||||
// `builtin` boolean because SkillInstaller leaves the boolean NULL on
|
||||
// user-installed rows; sorting on name instead of create_time because
|
||||
// the 20 seeded builtins share a near-identical create_time and looked
|
||||
// random within the group (issue #48).
|
||||
wrapper.orderByAsc(SkillEntity::getSkillType)
|
||||
.orderByAsc(SkillEntity::getName);
|
||||
SkillCatalogSort catalogSort = SkillCatalogSort.parse(sort);
|
||||
if (runtime != null && !runtime.isBlank() && !"all".equalsIgnoreCase(runtime)
|
||||
|| catalogSort == SkillCatalogSort.RECOMMENDED
|
||||
|| catalogSort == SkillCatalogSort.STATUS
|
||||
|| catalogSort == SkillCatalogSort.TYPE) {
|
||||
List<SkillEntity> filtered = skillMapper.selectList(wrapper).stream()
|
||||
.filter(s -> SkillCatalogSorter.runtimeMatches(s, runtime))
|
||||
.toList();
|
||||
List<SkillEntity> sorted = SkillCatalogSorter.sortEntities(filtered, catalogSort, pinnedSkillIds);
|
||||
long total = sorted.size();
|
||||
int safePage = Math.max(page, 1);
|
||||
int safeSize = Math.max(size, 1);
|
||||
int from = Math.min((safePage - 1) * safeSize, sorted.size());
|
||||
int to = Math.min(from + safeSize, sorted.size());
|
||||
pageParam.setRecords(sorted.subList(from, to));
|
||||
pageParam.setTotal(total);
|
||||
return pageParam;
|
||||
}
|
||||
|
||||
if (catalogSort == SkillCatalogSort.NAME) {
|
||||
wrapper.orderByAsc(SkillEntity::getName);
|
||||
} else if (catalogSort == SkillCatalogSort.UPDATED) {
|
||||
wrapper.orderByDesc(SkillEntity::getUpdateTime)
|
||||
.orderByAsc(SkillEntity::getName);
|
||||
} else {
|
||||
wrapper.orderByAsc(SkillEntity::getSkillType)
|
||||
.orderByAsc(SkillEntity::getName);
|
||||
}
|
||||
|
||||
return skillMapper.selectPage(pageParam, wrapper);
|
||||
}
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
package vip.mate.skill.usage;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.skill.repository.SkillUsageStatMapper;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SkillUsageService {
|
||||
|
||||
private final SkillUsageStatMapper mapper;
|
||||
|
||||
public void recordLoaded(ResolvedSkill skill, Long agentId, String conversationId,
|
||||
String filePath, int tokenEstimate) {
|
||||
if (skill == null || skill.getName() == null || skill.getName().isBlank()) return;
|
||||
try {
|
||||
Long scopedAgentId = agentId != null ? agentId : 0L;
|
||||
String scopedConversationId = blankToEmpty(conversationId);
|
||||
SkillUsageStatEntity row = mapper.selectOne(new LambdaQueryWrapper<SkillUsageStatEntity>()
|
||||
.eq(SkillUsageStatEntity::getSkillName, skill.getName())
|
||||
.eq(SkillUsageStatEntity::getAgentId, scopedAgentId)
|
||||
.eq(SkillUsageStatEntity::getConversationId, scopedConversationId)
|
||||
.last("LIMIT 1"));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (row == null) {
|
||||
row = new SkillUsageStatEntity();
|
||||
row.setSkillName(skill.getName());
|
||||
row.setSkillId(skill.getId());
|
||||
row.setAgentId(scopedAgentId);
|
||||
row.setConversationId(scopedConversationId);
|
||||
row.setLoadCount(1L);
|
||||
row.setLastLoadedAt(now);
|
||||
row.setLastFilePath(filePath);
|
||||
row.setLastTokenEstimate(tokenEstimate);
|
||||
row.setDeleted(0);
|
||||
mapper.insert(row);
|
||||
} else {
|
||||
row.setSkillId(skill.getId());
|
||||
row.setLoadCount((row.getLoadCount() == null ? 0L : row.getLoadCount()) + 1);
|
||||
row.setLastLoadedAt(now);
|
||||
row.setLastFilePath(filePath);
|
||||
row.setLastTokenEstimate(tokenEstimate);
|
||||
mapper.updateById(row);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to record skill usage for {}: {}", skill.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public Set<String> recentLoadedSkillNames(Long agentId, int limit) {
|
||||
if (agentId == null || limit <= 0) return Set.of();
|
||||
try {
|
||||
List<SkillUsageStatEntity> rows = mapper.selectList(new LambdaQueryWrapper<SkillUsageStatEntity>()
|
||||
.eq(SkillUsageStatEntity::getAgentId, agentId)
|
||||
.isNotNull(SkillUsageStatEntity::getLastLoadedAt)
|
||||
.orderByDesc(SkillUsageStatEntity::getLastLoadedAt)
|
||||
.last("LIMIT " + Math.min(limit, 50)));
|
||||
return rows.stream()
|
||||
.map(SkillUsageStatEntity::getSkillName)
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to read recent skill usage for agent {}: {}", agentId, e.getMessage());
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Set<String> frequentlyLoadedSkillNames(int limit) {
|
||||
if (limit <= 0) return Set.of();
|
||||
try {
|
||||
List<SkillUsageStatEntity> rows = mapper.selectList(new LambdaQueryWrapper<SkillUsageStatEntity>()
|
||||
.orderByDesc(SkillUsageStatEntity::getLoadCount)
|
||||
.orderByDesc(SkillUsageStatEntity::getLastLoadedAt)
|
||||
.last("LIMIT " + Math.min(limit, 50)));
|
||||
return rows.stream()
|
||||
.map(SkillUsageStatEntity::getSkillName)
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to read frequent skill usage: {}", e.getMessage());
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String blankToEmpty(String value) {
|
||||
return value == null || value.isBlank() ? "" : value;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package vip.mate.skill.usage;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("mate_skill_usage_stat")
|
||||
public class SkillUsageStatEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private String skillName;
|
||||
private Long skillId;
|
||||
private Long agentId;
|
||||
private String conversationId;
|
||||
private Long loadCount;
|
||||
private LocalDateTime lastLoadedAt;
|
||||
private String lastFilePath;
|
||||
private Integer lastTokenEstimate;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -4,11 +4,18 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.TokenEstimator;
|
||||
import vip.mate.skill.runtime.SkillCatalogSort;
|
||||
import vip.mate.skill.runtime.SkillCatalogSorter;
|
||||
import vip.mate.skill.runtime.SkillFileAccessPolicy;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.skill.usage.SkillUsageService;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@ -27,6 +34,7 @@ public class SkillFileTool {
|
||||
|
||||
private final SkillRuntimeService runtimeService;
|
||||
private final SkillFileAccessPolicy accessPolicy;
|
||||
private final SkillUsageService usageService;
|
||||
|
||||
@Tool(description = """
|
||||
Read a file from a skill's directory (SKILL.md, references/, or scripts/).
|
||||
@ -49,7 +57,9 @@ public class SkillFileTool {
|
||||
|
||||
@JsonProperty(required = true)
|
||||
@JsonPropertyDescription("Relative file path (e.g., 'references/doc.md' or 'scripts/run.py')")
|
||||
String filePath
|
||||
String filePath,
|
||||
|
||||
@Nullable ToolContext ctx
|
||||
) {
|
||||
log.info("Reading skill file: skill={}, path={}", skillName, filePath);
|
||||
|
||||
@ -62,6 +72,9 @@ public class SkillFileTool {
|
||||
// 特殊处理:读取 SKILL.md
|
||||
if ("SKILL.md".equals(filePath)) {
|
||||
if (skill.getContent() != null && !skill.getContent().isBlank()) {
|
||||
log.info("Skill loaded: skill={}, path=SKILL.md, bytes={}, estimatedTokens={}",
|
||||
skillName, skill.getContent().length(), TokenEstimator.estimateTokens(skill.getContent()));
|
||||
recordLoaded(skill, "SKILL.md", skill.getContent(), ctx);
|
||||
return skill.getContent();
|
||||
}
|
||||
return "Error: SKILL.md content not available";
|
||||
@ -89,7 +102,9 @@ public class SkillFileTool {
|
||||
}
|
||||
|
||||
String content = Files.readString(resolvedPath);
|
||||
log.info("Successfully read skill file: {} bytes", content.length());
|
||||
log.info("Skill loaded: skill={}, path={}, bytes={}, estimatedTokens={}",
|
||||
skillName, filePath, content.length(), TokenEstimator.estimateTokens(content));
|
||||
recordLoaded(skill, filePath, content, ctx);
|
||||
return content;
|
||||
|
||||
} catch (Exception e) {
|
||||
@ -98,6 +113,16 @@ public class SkillFileTool {
|
||||
}
|
||||
}
|
||||
|
||||
private void recordLoaded(ResolvedSkill skill, String filePath, String content, @Nullable ToolContext ctx) {
|
||||
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||
usageService.recordLoaded(
|
||||
skill,
|
||||
origin.agentId(),
|
||||
origin.conversationId(),
|
||||
filePath,
|
||||
TokenEstimator.estimateTokens(content));
|
||||
}
|
||||
|
||||
@Tool(description = """
|
||||
List all files in a skill's references/ and scripts/ directories.
|
||||
Use this to explore what files are available in a skill before reading them.
|
||||
@ -161,10 +186,36 @@ public class SkillFileTool {
|
||||
|
||||
Returns: A formatted list of active skills with name, icon, and description.
|
||||
""")
|
||||
public String listAvailableSkills() {
|
||||
public String listAvailableSkills(
|
||||
@JsonProperty(required = false)
|
||||
@JsonPropertyDescription("Optional keyword matched against skill name or description")
|
||||
String keyword,
|
||||
|
||||
@JsonProperty(required = false)
|
||||
@JsonPropertyDescription("Optional source filter: all, builtin, dynamic, mcp, acp")
|
||||
String source,
|
||||
|
||||
@JsonProperty(required = false)
|
||||
@JsonPropertyDescription("Optional status filter: all, ready, setup_needed, disabled, blocked")
|
||||
String status,
|
||||
|
||||
@JsonProperty(required = false)
|
||||
@JsonPropertyDescription("Maximum number of skills to return, default 20, max 50")
|
||||
Integer limit
|
||||
) {
|
||||
log.info("Listing available skills");
|
||||
|
||||
List<ResolvedSkill> activeSkills = runtimeService.getActiveSkills();
|
||||
int safeLimit = limit == null || limit <= 0 ? 20 : Math.min(limit, 50);
|
||||
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
|
||||
List<ResolvedSkill> activeSkills = SkillCatalogSorter.sortResolved(
|
||||
runtimeService.getActiveSkills().stream()
|
||||
.filter(s -> SkillCatalogSorter.sourceMatches(s, source))
|
||||
.filter(s -> SkillCatalogSorter.runtimeMatches(s, status))
|
||||
.filter(s -> kw.isEmpty()
|
||||
|| containsIgnoreCase(s.getName(), kw)
|
||||
|| containsIgnoreCase(s.getDescription(), kw))
|
||||
.toList(),
|
||||
SkillCatalogSort.RECOMMENDED);
|
||||
|
||||
if (activeSkills.isEmpty()) {
|
||||
return "No skills are currently available.";
|
||||
@ -178,27 +229,39 @@ public class SkillFileTool {
|
||||
sb.append("To use any of them, call:\n");
|
||||
sb.append(" readSkillFile(skillName=\"<name from below>\", filePath=\"SKILL.md\")\n");
|
||||
sb.append("then follow what SKILL.md tells you (typically `runSkillScript`).\n\n");
|
||||
sb.append("| Skill name | Description |\n");
|
||||
sb.append("|------------|-------------|\n");
|
||||
for (ResolvedSkill skill : activeSkills) {
|
||||
sb.append("| Skill name | Status | Description |\n");
|
||||
sb.append("|------------|--------|-------------|\n");
|
||||
for (ResolvedSkill skill : activeSkills.stream().limit(safeLimit).toList()) {
|
||||
sb.append("| `").append(skill.getName()).append("`");
|
||||
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
|
||||
sb.append(" ").append(skill.getIcon());
|
||||
}
|
||||
sb.append(" | ");
|
||||
sb.append(" | ").append(statusToken(skill)).append(" | ");
|
||||
if (skill.getDescription() != null && !skill.getDescription().isBlank()) {
|
||||
String desc = skill.getDescription();
|
||||
if (desc.length() > 200) {
|
||||
desc = desc.substring(0, 200) + "...";
|
||||
if (desc.length() > 120) {
|
||||
desc = desc.substring(0, 120) + "...";
|
||||
}
|
||||
sb.append(desc.replace("|", "\\|").replace("\n", " "));
|
||||
}
|
||||
sb.append(" |\n");
|
||||
}
|
||||
sb.append("\nTotal: ").append(activeSkills.size()).append(" skill(s).");
|
||||
sb.append("\nShowing: ").append(Math.min(safeLimit, activeSkills.size()))
|
||||
.append(" of ").append(activeSkills.size()).append(" skill(s).");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean containsIgnoreCase(String value, String lowerCaseNeedle) {
|
||||
return value != null && value.toLowerCase().contains(lowerCaseNeedle);
|
||||
}
|
||||
|
||||
private static String statusToken(ResolvedSkill skill) {
|
||||
if (skill.isSecurityBlocked()) return "blocked";
|
||||
if (!skill.isEnabled()) return "disabled";
|
||||
if (!SkillRuntimeService.passesActiveGate(skill)) return "setup-needed";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void formatTree(StringBuilder sb, Map<String, Object> tree, String indent) {
|
||||
for (Map.Entry<String, Object> entry : tree.entrySet()) {
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS mate_skill_usage_stat (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
skill_name VARCHAR(128) NOT NULL,
|
||||
skill_id BIGINT,
|
||||
agent_id BIGINT NOT NULL DEFAULT 0,
|
||||
conversation_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
load_count BIGINT NOT NULL DEFAULT 0,
|
||||
last_loaded_at TIMESTAMP,
|
||||
last_file_path VARCHAR(512),
|
||||
last_token_estimate INT,
|
||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_skill_usage_scope
|
||||
ON mate_skill_usage_stat (skill_name, agent_id, conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_usage_agent_recent
|
||||
ON mate_skill_usage_stat (agent_id, last_loaded_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_usage_name_recent
|
||||
ON mate_skill_usage_stat (skill_name, last_loaded_at);
|
||||
@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS mate_skill_usage_stat (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
skill_name VARCHAR(128) NOT NULL,
|
||||
skill_id BIGINT,
|
||||
agent_id BIGINT NOT NULL DEFAULT 0,
|
||||
conversation_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
load_count BIGINT NOT NULL DEFAULT 0,
|
||||
last_loaded_at DATETIME(3),
|
||||
last_file_path VARCHAR(512),
|
||||
last_token_estimate INT,
|
||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE KEY uk_skill_usage_scope (skill_name, agent_id, conversation_id),
|
||||
KEY idx_skill_usage_agent_recent (agent_id, last_loaded_at),
|
||||
KEY idx_skill_usage_name_recent (skill_name, last_loaded_at)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
||||
COMMENT = 'Skill runtime usage statistics keyed by skill and invocation scope.';
|
||||
@ -144,14 +144,18 @@ export const conversationApi = {
|
||||
|
||||
// ==================== Skill ====================
|
||||
export const skillApi = {
|
||||
/** RFC-042 §2.1 — paginated skill listing with search/type/enabled/scanStatus filters */
|
||||
/** Paginated skill listing with search, source, status, and sort filters. */
|
||||
page: (params: {
|
||||
page?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
skillType?: string
|
||||
source?: string
|
||||
sort?: string
|
||||
runtime?: string
|
||||
agentId?: string | number
|
||||
enabled?: boolean
|
||||
/** 'PASSED' / 'FAILED' — filters by security_scan_status (RFC-042 §2.3.5) */
|
||||
/** 'PASSED' / 'FAILED' — filters by security_scan_status. */
|
||||
scanStatus?: string
|
||||
} = {}) => http.get('/skills', { params }),
|
||||
/** Tab count aggregate — returns { all, builtin, mcp, dynamic } */
|
||||
|
||||
@ -2217,6 +2217,13 @@ export default {
|
||||
disabled: 'Disabled',
|
||||
scanFailed: 'Scan failed',
|
||||
},
|
||||
sort: {
|
||||
recommended: 'Recommended',
|
||||
name: 'Name',
|
||||
status: 'Status',
|
||||
source: 'Source',
|
||||
updated: 'Recently updated',
|
||||
},
|
||||
security: {
|
||||
scanned: 'Scanned',
|
||||
scanFailed: 'Scan failed',
|
||||
|
||||
@ -2221,6 +2221,13 @@ export default {
|
||||
disabled: '已禁用',
|
||||
scanFailed: '扫描失败',
|
||||
},
|
||||
sort: {
|
||||
recommended: '推荐排序',
|
||||
name: '按名称',
|
||||
status: '按状态',
|
||||
source: '按来源',
|
||||
updated: '最近更新',
|
||||
},
|
||||
security: {
|
||||
scanned: '已扫描',
|
||||
scanFailed: '扫描失败',
|
||||
|
||||
@ -1246,7 +1246,9 @@ async function selectConversation(conv: Conversation) {
|
||||
}
|
||||
|
||||
function newConversation() {
|
||||
resetStreamingState()
|
||||
// Creating a new chat is just local navigation. Keep any previous backend
|
||||
// run alive so the user can return and reconnect to it later.
|
||||
resetForNewConversation()
|
||||
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
@ -62,6 +62,13 @@
|
||||
<option value="disabled">{{ t('skills.filter.disabled') }}</option>
|
||||
<option value="scan_failed">{{ t('skills.filter.scanFailed') }}</option>
|
||||
</select>
|
||||
<select v-model="query.sort" class="skill-status-filter" @change="onFilterChange">
|
||||
<option value="recommended">{{ t('skills.sort.recommended') }}</option>
|
||||
<option value="name">{{ t('skills.sort.name') }}</option>
|
||||
<option value="status">{{ t('skills.sort.status') }}</option>
|
||||
<option value="type">{{ t('skills.sort.source') }}</option>
|
||||
<option value="updated">{{ t('skills.sort.updated') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Skill grid — RFC-090 §4.2 (Phase 1 slim).
|
||||
@ -159,7 +166,7 @@
|
||||
v-model:page="query.page"
|
||||
v-model:size="query.size"
|
||||
:total="total"
|
||||
:sizes="[10, 20, 50]"
|
||||
:sizes="[20, 50]"
|
||||
@change="onPagerChange"
|
||||
/>
|
||||
</div>
|
||||
@ -657,14 +664,13 @@ const creating = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const showImportDialog = ref(false)
|
||||
|
||||
/** Paginated query state — RFC-042 §2.1 + §2.3.5 */
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
size: 20,
|
||||
keyword: '',
|
||||
skillType: 'all' as string,
|
||||
/** '' = all | 'enabled' | 'disabled' | 'scan_failed' (RFC-042 §2.3.5 unified status filter) */
|
||||
statusFilter: '' as string,
|
||||
sort: 'recommended' as string,
|
||||
})
|
||||
|
||||
/** Per-skill UI state for the RFC-042 §2.3 findings panel. */
|
||||
@ -914,11 +920,12 @@ function onPagerChange() {
|
||||
loadSkills()
|
||||
}
|
||||
|
||||
async function loadSkills() {
|
||||
async function loadSkills(allowPageClamp = true) {
|
||||
try {
|
||||
const params: Record<string, unknown> = { page: query.page, size: query.size }
|
||||
if (query.keyword) params.keyword = query.keyword.trim()
|
||||
if (query.skillType && query.skillType !== 'all') params.skillType = query.skillType
|
||||
if (query.sort) params.sort = query.sort
|
||||
// Map the single status filter onto the backend's two independent params:
|
||||
// enabled (bool) and scanStatus (PASSED/FAILED). scan_failed implies any enabled state.
|
||||
if (query.statusFilter === 'enabled') params.enabled = true
|
||||
@ -928,7 +935,6 @@ async function loadSkills() {
|
||||
const res: any = await skillApi.page(params)
|
||||
const data = res.data || {}
|
||||
const records: Skill[] = Array.isArray(data.records) ? data.records : []
|
||||
skills.value = records
|
||||
// Trust the backend total when it's positive. Only fall back to an inferred
|
||||
// floor when the backend reports 0 (broken pagination interceptor) — using
|
||||
// Math.max unconditionally produced an off-by-one whenever total was an
|
||||
@ -936,6 +942,12 @@ async function loadSkills() {
|
||||
// successor; issue #48).
|
||||
const reportedTotal = Number(data.total) || 0
|
||||
if (reportedTotal > 0) {
|
||||
const pageCount = Math.max(1, Math.ceil(reportedTotal / query.size))
|
||||
if (allowPageClamp && query.page > pageCount) {
|
||||
query.page = pageCount
|
||||
await loadSkills(false)
|
||||
return
|
||||
}
|
||||
total.value = reportedTotal
|
||||
} else if (records.length > 0) {
|
||||
total.value = records.length >= query.size
|
||||
@ -944,8 +956,14 @@ async function loadSkills() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[SkillMarket] backend returned records but total=0; rebuild server JAR to pick up the DbType fix')
|
||||
} else {
|
||||
if (allowPageClamp && query.page > 1) {
|
||||
query.page = 1
|
||||
await loadSkills(false)
|
||||
return
|
||||
}
|
||||
total.value = 0
|
||||
}
|
||||
skills.value = records
|
||||
} catch (e) {
|
||||
skills.value = []
|
||||
total.value = 0
|
||||
@ -1517,7 +1535,7 @@ function getSkillTypeLabel(type: string) {
|
||||
.cat-count { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); padding: 1px 6px; border-radius: 10px; font-size: 11px; }
|
||||
.cat-tab.active .cat-count { background: rgba(217, 119, 87, 0.2); color: var(--mc-primary); }
|
||||
|
||||
/* RFC-042 §2.1 — search + status filter bar (frosted glass) */
|
||||
/* Search and filter bar. */
|
||||
.skill-filter-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@ -1564,7 +1582,7 @@ html.dark .skill-status-filter:focus {
|
||||
|
||||
.skill-pagination { margin-top: 18px; display: flex; justify-content: center; }
|
||||
|
||||
/* RFC-042 §2.3 — security scan findings panel (frosted, non-EP) */
|
||||
/* Security scan findings panel. */
|
||||
.scan-badge-button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user