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