mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
391 lines
14 KiB
Java
391 lines
14 KiB
Java
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),改用 skillContent(SKILL.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>
|
||
* 优先级: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<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");
|
||
}
|
||
}
|