mateclaw/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java

391 lines
14 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/**
* 技能业务服务
* <p>
* 负责技能的 CRUD 管理、启用/禁用控制,以及与 Agent 运行时的集成。
* Skill 在 MateClaw 中的定位是"可扩展的能力模块",分为三种类型:
* <ul>
* <li>builtin — 系统内置技能(不可删除),通常对应预定义的 systemPrompt 片段</li>
* <li>mcp — 通过 MCP 协议连接外部工具服务器</li>
* <li>dynamic — 用户自定义的动态技能(可包含脚本或配置)</li>
* </ul>
*
* @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<SkillEntity> listSkills() {
return skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.orderByDesc(SkillEntity::getBuiltin)
.orderByDesc(SkillEntity::getCreateTime));
}
/**
* 获取已启用的技能列表Agent 运行时使用)
*/
public List<SkillEntity> listEnabledSkills() {
return skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getEnabled, true)
.orderByAsc(SkillEntity::getName));
}
/**
* 按类型获取技能列表
*/
public List<SkillEntity> listSkillsByType(String skillType) {
return skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getSkillType, skillType)
.orderByDesc(SkillEntity::getCreateTime));
}
/**
* 获取技能详情
*/
public SkillEntity getSkill(Long id) {
SkillEntity skill = skillMapper.selectById(id);
if (skill == null) {
throw new MateClawException("err.skill.not_found", "技能不存在: " + id);
}
return skill;
}
/**
* 创建技能
* 默认类型为 dynamic用户自定义非内置
*/
public SkillEntity createSkill(SkillEntity skill) {
// 验证名称不为空
if (skill.getName() == null || skill.getName().isBlank()) {
throw new MateClawException("err.skill.name_required", "技能名称不能为空");
}
// 检查名称唯一性
Long count = skillMapper.selectCount(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getName, skill.getName()));
if (count > 0) {
throw new MateClawException("err.skill.name_exists", "技能名称已存在: " + 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("err.skill.builtin_readonly", "内置技能不可删除: " + 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 预算控制)
* <p>
* 优化策略(对比旧版全量注入):
* <ol>
* <li>分层注入:先注入「技能目录」(名称+描述再按预算注入「技能详情」skillContent</li>
* <li>Token 预算控制:总字符数超过预算时,截断详情部分,只保留目录</li>
* <li>优先级builtin 技能优先注入详情,其次按名称排序</li>
* <li>不再将 sourceCode 全量注入(旧版会爆 token改用 skillContentSKILL.md 协议)</li>
* </ol>
*
* @return systemPrompt 增强片段,可直接拼接到 Agent 的 systemPrompt 末尾
*/
public String buildSkillPromptEnhancement() {
return buildSkillPromptEnhancement(DEFAULT_SKILL_PROMPT_BUDGET);
}
/**
* 构建技能 Prompt 增强片段(可指定 Token 预算)
*
* @param charBudget 最大字符预算(超出时自动截断详情)
*/
public String buildSkillPromptEnhancement(int charBudget) {
List<SkillEntity> 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<SkillEntity> 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;
}
/**
* 获取技能的可注入内容
* <p>
* 优先级skillContentSKILL.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<String, List<String>> 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");
}
}