feat(skill): catalog sort + usage stats

This commit is contained in:
matevip 2026-05-04 11:55:35 +08:00
parent 68e1c6f50d
commit 3d50b9c132
17 changed files with 796 additions and 119 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 skills5分钟过期
@ -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.

View File

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

View File

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

View File

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

View File

@ -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()) {

View File

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

View File

@ -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.';

View File

@ -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 } */

View File

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

View File

@ -2221,6 +2221,13 @@ export default {
disabled: '已禁用',
scanFailed: '扫描失败',
},
sort: {
recommended: '推荐排序',
name: '按名称',
status: '按状态',
source: '按来源',
updated: '最近更新',
},
security: {
scanned: '已扫描',
scanFailed: '扫描失败',

View File

@ -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 = []
}

View File

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