From 3d50b9c132210e748eb8f10ed7b556f52aafad2a Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 4 May 2026 11:55:35 +0800 Subject: [PATCH] feat(skill): catalog sort + usage stats --- .../vip/mate/agent/AgentGraphBuilder.java | 9 +- .../skill/controller/SkillController.java | 181 +++++++++++------ .../repository/SkillUsageStatMapper.java | 9 + .../mate/skill/runtime/SkillCatalogSort.java | 20 ++ .../skill/runtime/SkillCatalogSorter.java | 188 ++++++++++++++++++ .../skill/runtime/SkillRuntimeService.java | 124 +++++++++--- .../vip/mate/skill/service/SkillService.java | 68 ++++++- .../mate/skill/usage/SkillUsageService.java | 97 +++++++++ .../skill/usage/SkillUsageStatEntity.java | 34 ++++ .../vip/mate/tool/builtin/SkillFileTool.java | 85 +++++++- .../db/migration/h2/V87__skill_usage_stat.sql | 21 ++ .../migration/mysql/V87__skill_usage_stat.sql | 19 ++ mateclaw-ui/src/api/index.ts | 8 +- mateclaw-ui/src/i18n/locales/en-US.ts | 7 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 7 + mateclaw-ui/src/views/ChatConsole.vue | 4 +- mateclaw-ui/src/views/SkillMarket.vue | 34 +++- 17 files changed, 796 insertions(+), 119 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/repository/SkillUsageStatMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogSort.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogSorter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageStatEntity.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V87__skill_usage_stat.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V87__skill_usage_stat.sql diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 2642cde5..237f1be0 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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 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 boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId()); - String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(boundSkillIds); + String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement( + boundSkillIds, boundTools, maxInputTokens, entity.getId()); // 工具调用指导 String toolGuidance = """ diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index e0d46ec6..979c21fa 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -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 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 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 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 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 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 acpSkills = acpSkillBridge.listAcpDerivedSkillEntities(); - if (!acpSkills.isEmpty()) { - String kw = keyword == null ? "" : keyword.trim().toLowerCase(); - List 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 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 pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of(); + if (pinnedSkillIds == null) pinnedSkillIds = Set.of(); + IPage dbPage = skillService.pageSkills( + page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, pinnedSkillIds); + List 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: + *
+     *   [all DB rows sorted by SQL] + [unshadowed live virtual rows]
+     * 
+ * + * 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 dbRecords, + List virtualSkills, + long dbTotal, + int page, + int size) { + List records = new ArrayList<>(dbRecords == null ? List.of() : dbRecords); + List 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 records, long total) {} + + private List 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 realNames = realSkillNames(); + List 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 filterVirtualSkills(List virtualSkills, + Set 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 realSkillNames() { return skillService.listSkills().stream() .map(SkillEntity::getName) diff --git a/mateclaw-server/src/main/java/vip/mate/skill/repository/SkillUsageStatMapper.java b/mateclaw-server/src/main/java/vip/mate/skill/repository/SkillUsageStatMapper.java new file mode 100644 index 00000000..10423d1f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/repository/SkillUsageStatMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogSort.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogSort.java new file mode 100644 index 00000000..52e31154 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogSort.java @@ -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; + }; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogSorter.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogSorter.java new file mode 100644 index 00000000..38f66b5b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogSorter.java @@ -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 sortEntities(List skills, SkillCatalogSort sort) { + return sortEntities(skills, sort, Set.of()); + } + + public static List sortEntities(List skills, SkillCatalogSort sort, + Set pinnedSkillIds) { + if (skills == null || skills.isEmpty()) return List.of(); + return skills.stream() + .sorted(entityComparator(sort, pinnedSkillIds)) + .toList(); + } + + public static List sortResolved(List 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 entityComparator(SkillCatalogSort sort) { + return entityComparator(sort, Set.of()); + } + + static Comparator entityComparator(SkillCatalogSort sort, Set pinnedSkillIds) { + SkillCatalogSort normalized = sort == null ? SkillCatalogSort.RECOMMENDED : sort; + Set 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 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index e8de16a4..77dd8a07 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -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 boundSkillIds) { + return buildSkillPromptEnhancement(boundSkillIds, null, null, null); + } + + /** + * 构建技能目录提示片段。 + * + * @param boundSkillIds Agent 绑定的 skill ID 集合。null 表示使用全局默认(无绑定)。 + * @param effectiveToolNames 当前 agent 可见的工具名集合。null 表示不按 agent 绑定限制过滤。 + * @param maxInputTokens 当前模型最大输入窗口,用于控制目录大小。 + */ + public String buildSkillPromptEnhancement(Set boundSkillIds, + Set effectiveToolNames, + Integer maxInputTokens) { + return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null); + } + + public String buildSkillPromptEnhancement(Set boundSkillIds, + Set effectiveToolNames, + Integer maxInputTokens, + Long agentId) { List 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 visibleSkills = activeSkills.stream() + .filter(s -> isVisibleWithTools(s, effectiveToolNames)) + .collect(java.util.stream.Collectors.toList()); + if (visibleSkills.isEmpty()) return ""; + + Set boundIds = boundSkillIds == null ? Set.of() : boundSkillIds; + int maxEntries = promptCatalogEntryLimit(maxInputTokens); + int descLimit = promptDescriptionLimit(maxInputTokens); + Set recentNames = usageService.recentLoadedSkillNames(agentId, 8); + Set frequentNames = usageService.frequentlyLoadedSkillNames(8); + List 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 pinned = sorted.stream() + .filter(s -> s.getId() != null && boundIds.contains(s.getId())) + .toList(); + LinkedHashSet 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=, 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=, 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 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 effectiveToolNames) { + if (effectiveToolNames == null) return true; + Set 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. diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index 37842f2c..364bc705 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -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 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 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 pageSkills(int page, int size, String keyword, + String skillType, Boolean enabled, + String scanStatus, + String sort, + String source, + String runtime, + Set pinnedSkillIds) { Page pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1)); LambdaQueryWrapper 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 filtered = skillMapper.selectList(wrapper).stream() + .filter(s -> SkillCatalogSorter.runtimeMatches(s, runtime)) + .toList(); + List 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); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java new file mode 100644 index 00000000..cea08e4c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java @@ -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() + .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 recentLoadedSkillNames(Long agentId, int limit) { + if (agentId == null || limit <= 0) return Set.of(); + try { + List rows = mapper.selectList(new LambdaQueryWrapper() + .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 frequentlyLoadedSkillNames(int limit) { + if (limit <= 0) return Set.of(); + try { + List rows = mapper.selectList(new LambdaQueryWrapper() + .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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageStatEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageStatEntity.java new file mode 100644 index 00000000..434599a8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageStatEntity.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index d1da943d..9622a9a5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -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 activeSkills = runtimeService.getActiveSkills(); + int safeLimit = limit == null || limit <= 0 ? 20 : Math.min(limit, 50); + String kw = keyword == null ? "" : keyword.trim().toLowerCase(); + List 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=\"\", 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 tree, String indent) { for (Map.Entry entry : tree.entrySet()) { diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V87__skill_usage_stat.sql b/mateclaw-server/src/main/resources/db/migration/h2/V87__skill_usage_stat.sql new file mode 100644 index 00000000..35bf3d4d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V87__skill_usage_stat.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V87__skill_usage_stat.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V87__skill_usage_stat.sql new file mode 100644 index 00000000..c8b0fc87 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V87__skill_usage_stat.sql @@ -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.'; diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index f9e6c9bf..45476375 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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 } */ diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 4744e15c..7aefed74 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 80191c23..4b5a1e7f 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2221,6 +2221,13 @@ export default { disabled: '已禁用', scanFailed: '扫描失败', }, + sort: { + recommended: '推荐排序', + name: '按名称', + status: '按状态', + source: '按来源', + updated: '最近更新', + }, security: { scanned: '已扫描', scanFailed: '扫描失败', diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 716dd532..1eff5336 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -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 = [] } diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index 340809e9..cc906277 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -62,6 +62,13 @@ +