package vip.mate.skill.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.workspace.SkillWorkspaceManager; import vip.mate.skill.workspace.SkillWorkspaceProperties; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * 技能业务服务 *

* 负责技能的 CRUD 管理、启用/禁用控制,以及与 Agent 运行时的集成。 * Skill 在 MateClaw 中的定位是"可扩展的能力模块",分为三种类型: *

* * @author MateClaw Team */ @Slf4j @Service @RequiredArgsConstructor public class SkillService { private final SkillMapper skillMapper; private final SkillWorkspaceManager workspaceManager; private final SkillWorkspaceProperties workspaceProperties; private vip.mate.skill.runtime.SkillRuntimeService runtimeService; /** * 延迟注入 SkillRuntimeService 避免循环依赖 */ public void setRuntimeService(vip.mate.skill.runtime.SkillRuntimeService runtimeService) { this.runtimeService = runtimeService; } // ==================== CRUD ==================== /** * 获取所有技能列表(管理页面使用) * 排序:内置优先,然后按创建时间倒序 */ public List listSkills() { return skillMapper.selectList(new LambdaQueryWrapper() .orderByDesc(SkillEntity::getBuiltin) .orderByDesc(SkillEntity::getCreateTime)); } /** * 获取已启用的技能列表(Agent 运行时使用) */ public List listEnabledSkills() { return skillMapper.selectList(new LambdaQueryWrapper() .eq(SkillEntity::getEnabled, true) .orderByAsc(SkillEntity::getName)); } /** * 按类型获取技能列表 */ public List listSkillsByType(String skillType) { return skillMapper.selectList(new LambdaQueryWrapper() .eq(SkillEntity::getSkillType, skillType) .orderByDesc(SkillEntity::getCreateTime)); } /** * 获取技能详情 */ public SkillEntity getSkill(Long id) { SkillEntity skill = skillMapper.selectById(id); if (skill == null) { throw new MateClawException("技能不存在: " + id); } return skill; } /** * 创建技能 * 默认类型为 dynamic(用户自定义),非内置 */ public SkillEntity createSkill(SkillEntity skill) { // 验证名称不为空 if (skill.getName() == null || skill.getName().isBlank()) { throw new MateClawException("技能名称不能为空"); } // 检查名称唯一性 Long count = skillMapper.selectCount(new LambdaQueryWrapper() .eq(SkillEntity::getName, skill.getName())); if (count > 0) { throw new MateClawException("技能名称已存在: " + skill.getName()); } // 设置默认值 skill.setBuiltin(false); if (skill.getEnabled() == null) { skill.setEnabled(true); } // 前端只识别 builtin/mcp/dynamic,用户新建默认为 dynamic if (skill.getSkillType() == null || skill.getSkillType().isBlank()) { skill.setSkillType("dynamic"); } // 默认版本号 if (skill.getVersion() == null || skill.getVersion().isBlank()) { skill.setVersion("1.0.0"); } skillMapper.insert(skill); log.info("Created skill: {} (type={})", skill.getName(), skill.getSkillType()); // 自动初始化工作区目录 if (workspaceProperties.isAutoInit() && !hasExplicitSkillDir(skill)) { workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent()); } // 刷新 runtime cache if (runtimeService != null) { runtimeService.refreshActiveSkills(); } return skill; } /** * 更新技能 * 内置技能只允许修改 enabled、configJson、description */ public SkillEntity updateSkill(SkillEntity skill) { SkillEntity existing = getSkill(skill.getId()); if (Boolean.TRUE.equals(existing.getBuiltin())) { // 内置技能:只允许修改有限字段 existing.setEnabled(skill.getEnabled() != null ? skill.getEnabled() : existing.getEnabled()); existing.setConfigJson(skill.getConfigJson()); existing.setDescription(skill.getDescription() != null ? skill.getDescription() : existing.getDescription()); // builtin skill 也允许更新 skillContent(用于维护 fallback 内容) if (skill.getSkillContent() != null) { existing.setSkillContent(skill.getSkillContent()); } skillMapper.updateById(existing); log.info("Updated builtin skill (limited): {}", existing.getName()); // builtin skill 也同步 workspace SKILL.md syncSkillContentToWorkspace(existing); // 刷新 runtime cache if (runtimeService != null) { runtimeService.refreshActiveSkills(); } return existing; } // 非内置技能:允许修改所有字段,但不允许改为 builtin skill.setBuiltin(false); skillMapper.updateById(skill); log.info("Updated skill: {}", skill.getName()); // 若 skillContent 变更且约定工作区存在,同步 SKILL.md syncSkillContentToWorkspace(skill); // 刷新 runtime cache if (runtimeService != null) { runtimeService.refreshActiveSkills(); } return skill; } /** * 删除技能 * 内置技能不可删除 */ public void deleteSkill(Long id) { SkillEntity skill = getSkill(id); if (Boolean.TRUE.equals(skill.getBuiltin())) { throw new MateClawException("内置技能不可删除: " + skill.getName()); } skillMapper.deleteById(id); log.info("Deleted skill: {}", skill.getName()); // 归档工作区目录 if ("archive".equals(workspaceProperties.getDeletePolicy())) { workspaceManager.archiveWorkspace(skill.getName()); } // 刷新 runtime cache if (runtimeService != null) { runtimeService.refreshActiveSkills(); } } /** * 启用/禁用技能 */ public SkillEntity toggleSkill(Long id, boolean enabled) { SkillEntity skill = getSkill(id); skill.setEnabled(enabled); skillMapper.updateById(skill); log.info("Skill {} {}", skill.getName(), enabled ? "enabled" : "disabled"); // 刷新 runtime cache if (runtimeService != null) { runtimeService.refreshActiveSkills(); } return skill; } // ==================== Agent 运行时集成 ==================== /** * Token 预算上限(字符数近似值,1 token ≈ 2 个中文字 / 4 个英文字符) * 默认 6000 字符 ≈ ~2000 tokens,为对话上下文预留足够空间 */ private static final int DEFAULT_SKILL_PROMPT_BUDGET = 6000; /** * 构建技能 Prompt 增强片段(带 Token 预算控制) *

* 优化策略(对比旧版全量注入): *

    *
  1. 分层注入:先注入「技能目录」(名称+描述),再按预算注入「技能详情」(skillContent)
  2. *
  3. Token 预算控制:总字符数超过预算时,截断详情部分,只保留目录
  4. *
  5. 优先级:builtin 技能优先注入详情,其次按名称排序
  6. *
  7. 不再将 sourceCode 全量注入(旧版会爆 token),改用 skillContent(SKILL.md 协议)
  8. *
* * @return systemPrompt 增强片段,可直接拼接到 Agent 的 systemPrompt 末尾 */ public String buildSkillPromptEnhancement() { return buildSkillPromptEnhancement(DEFAULT_SKILL_PROMPT_BUDGET); } /** * 构建技能 Prompt 增强片段(可指定 Token 预算) * * @param charBudget 最大字符预算(超出时自动截断详情) */ public String buildSkillPromptEnhancement(int charBudget) { List enabledSkills = listEnabledSkills(); if (enabledSkills.isEmpty()) { return ""; } // --- 第一层:技能目录(始终注入,消耗很少的 token) --- StringBuilder catalog = new StringBuilder(); catalog.append("\n\n## Available Skills\n"); catalog.append("以下技能已启用,你可以在对话中根据用户需求灵活运用:\n\n"); for (SkillEntity skill : enabledSkills) { catalog.append("- **").append(skill.getName()).append("**"); if (skill.getIcon() != null && !skill.getIcon().isBlank()) { catalog.append(" ").append(skill.getIcon()); } if (skill.getDescription() != null && !skill.getDescription().isBlank()) { // 截取描述前 200 字符作为摘要 String desc = skill.getDescription(); if (desc.length() > 200) { desc = desc.substring(0, 200) + "..."; } catalog.append(" — ").append(desc); } catalog.append("\n"); } int remaining = charBudget - catalog.length(); if (remaining <= 200) { // 预算不足,只返回目录 return catalog.toString(); } // --- 第二层:技能详情(按优先级注入,受预算控制) --- // 排序优先级:builtin > 其他,然后按名称 List sorted = enabledSkills.stream() .sorted((a, b) -> { int builtinCmp = Boolean.compare( Boolean.TRUE.equals(b.getBuiltin()), Boolean.TRUE.equals(a.getBuiltin())); return builtinCmp != 0 ? builtinCmp : a.getName().compareTo(b.getName()); }) .toList(); StringBuilder details = new StringBuilder(); details.append("\n### Skill Details\n"); int detailLen = details.length(); for (SkillEntity skill : sorted) { String content = resolveSkillContent(skill); if (content == null || content.isBlank()) { continue; } // 每个技能的详情块 StringBuilder block = new StringBuilder(); block.append("\n#### ").append(skill.getName()).append("\n"); block.append(content).append("\n"); // 检查预算 if (detailLen + block.length() > remaining) { // 预算不足,尝试截断当前 skill 内容 int maxContentLen = remaining - detailLen - 60; // 留 60 字符给标题和截断提示 if (maxContentLen > 200) { block.setLength(0); block.append("\n#### ").append(skill.getName()).append("\n"); block.append(content, 0, Math.min(content.length(), maxContentLen)); block.append("\n...(truncated)\n"); details.append(block); } break; // 预算用尽,停止注入 } details.append(block); detailLen += block.length(); } return catalog.toString() + details; } /** * 获取技能的可注入内容 *

* 优先级:skillContent(SKILL.md 协议) > description * 不再使用 sourceCode(可能包含大量代码,容易爆 token) */ private String resolveSkillContent(SkillEntity skill) { // 优先使用 SKILL.md 内容(执行协议) if (skill.getSkillContent() != null && !skill.getSkillContent().isBlank()) { return skill.getSkillContent(); } // 回退到 description(兼容旧数据) return skill.getDescription(); } /** * 获取已启用技能的摘要信息(用于 Agent 状态展示) */ public Map> getEnabledSkillSummary() { return listEnabledSkills().stream() .collect(Collectors.groupingBy( SkillEntity::getSkillType, Collectors.mapping(SkillEntity::getName, Collectors.toList()) )); } // ==================== Workspace 集成辅助方法 ==================== /** * 同步 skillContent 到工作区 SKILL.md */ private void syncSkillContentToWorkspace(SkillEntity skill) { if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) { return; } if (workspaceManager.conventionWorkspaceExists(skill.getName())) { Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName()); Path skillMd = workspaceDir.resolve("SKILL.md"); try { Files.writeString(skillMd, skill.getSkillContent()); log.debug("Synced skillContent to workspace SKILL.md: {}", skillMd); } catch (Exception e) { log.warn("Failed to sync skillContent to workspace: {}", e.getMessage()); } } } /** * 检查 skill 是否有显式配置的 skillDir */ private boolean hasExplicitSkillDir(SkillEntity skill) { String configJson = skill.getConfigJson(); if (configJson == null || configJson.isBlank()) { return false; } return configJson.contains("skillDir") || configJson.contains("path") || configJson.contains("directory"); } }