mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
Full-stack AI assistant built on Spring AI Alibaba. Features: ReAct Agent, Plan-and-Execute, MCP Protocol, Multi-Model, Multi-Channel. Apache-2.0 License
296 lines
12 KiB
Java
296 lines
12 KiB
Java
package vip.mate.skill.runtime;
|
||
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import lombok.RequiredArgsConstructor;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.stereotype.Component;
|
||
import vip.mate.skill.model.SkillEntity;
|
||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||
|
||
import java.nio.file.Files;
|
||
import java.nio.file.Path;
|
||
import java.nio.file.Paths;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.stream.Collectors;
|
||
|
||
/**
|
||
* 技能包解析器
|
||
* 将 SkillEntity 解析为 ResolvedSkill(运行时可用的技能包)
|
||
* <p>
|
||
* 三级解析流程:
|
||
* <ol>
|
||
* <li>显式 skillDir(configJson.skillDir)→ source="directory"</li>
|
||
* <li>约定路径({workspace-root}/{skillName}/)→ source="convention"</li>
|
||
* <li>数据库 skillContent → source="database"</li>
|
||
* </ol>
|
||
* 解析后依次执行:安全扫描 → 依赖检查
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
@RequiredArgsConstructor
|
||
public class SkillPackageResolver {
|
||
|
||
private final SkillFrontmatterParser frontmatterParser;
|
||
private final SkillDirectoryScanner directoryScanner;
|
||
private final SkillSecurityService securityService;
|
||
private final SkillDependencyChecker dependencyChecker;
|
||
private final ObjectMapper objectMapper;
|
||
private final SkillWorkspaceManager workspaceManager;
|
||
|
||
/**
|
||
* 解析技能实体为运行时技能包(完整流程)
|
||
*/
|
||
public ResolvedSkill resolve(SkillEntity entity) {
|
||
String configuredDir = extractSkillDirString(entity);
|
||
Path skillDir = configuredDir != null ? Paths.get(configuredDir) : null;
|
||
|
||
ResolvedSkill resolved;
|
||
|
||
// 三级解析:explicit → convention → database
|
||
if (skillDir != null && Files.exists(skillDir) && Files.isDirectory(skillDir)) {
|
||
// 1. 显式配置的 skillDir
|
||
resolved = resolveFromDirectory(entity, skillDir, configuredDir, "directory");
|
||
} else {
|
||
// 2. 约定路径 {workspace-root}/{skillName}/
|
||
Path conventionPath = workspaceManager.resolveConventionPath(entity.getName());
|
||
if (Files.exists(conventionPath) && Files.isDirectory(conventionPath)) {
|
||
resolved = resolveFromDirectory(entity, conventionPath, conventionPath.toString(), "convention");
|
||
} else {
|
||
// 3. 数据库 skillContent
|
||
resolved = resolveFromDatabase(entity, configuredDir);
|
||
}
|
||
}
|
||
|
||
// 2. 安全扫描
|
||
applySecurity(resolved);
|
||
|
||
// 3. 依赖检查
|
||
applyDependencyCheck(resolved);
|
||
|
||
// 4. 综合判定 runtimeAvailable
|
||
resolveRuntimeAvailability(resolved);
|
||
|
||
return resolved;
|
||
}
|
||
|
||
// ==================== 阶段 1:内容解析 ====================
|
||
|
||
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
|
||
Path skillMd = skillDir.resolve("SKILL.md");
|
||
|
||
String content = "";
|
||
String description = entity.getDescription();
|
||
|
||
if (Files.exists(skillMd)) {
|
||
try {
|
||
content = Files.readString(skillMd);
|
||
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
|
||
if (!parsed.getDescription().isBlank()) {
|
||
description = parsed.getDescription();
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
|
||
}
|
||
}
|
||
|
||
Map<String, Object> references = directoryScanner.buildDirectoryTree(skillDir.resolve("references"));
|
||
Map<String, Object> scripts = directoryScanner.buildDirectoryTree(skillDir.resolve("scripts"));
|
||
|
||
return ResolvedSkill.builder()
|
||
.name(entity.getName())
|
||
.description(description)
|
||
.content(content)
|
||
.source(source)
|
||
.skillDir(skillDir)
|
||
.configuredSkillDir(configuredDir)
|
||
.runtimeAvailable(true) // 暂定,后续安全/依赖检查可能改写
|
||
.resolutionError(null)
|
||
.references(references)
|
||
.scripts(scripts)
|
||
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
|
||
.icon(entity.getIcon())
|
||
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
|
||
.build();
|
||
}
|
||
|
||
private ResolvedSkill resolveFromDatabase(SkillEntity entity, String configuredDir) {
|
||
String content = entity.getSkillContent() != null ? entity.getSkillContent() : "";
|
||
String description = entity.getDescription();
|
||
|
||
if (!content.isBlank()) {
|
||
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
|
||
if (!parsed.getDescription().isBlank()) {
|
||
description = parsed.getDescription();
|
||
}
|
||
}
|
||
|
||
boolean hasContent = !content.isBlank();
|
||
String error = null;
|
||
if (configuredDir != null) {
|
||
error = "Configured skillDir not found: " + configuredDir;
|
||
if (hasContent) {
|
||
error += " (fallback to database skillContent)";
|
||
}
|
||
} else if (!hasContent) {
|
||
error = "No skillDir configured and no skillContent available";
|
||
}
|
||
|
||
return ResolvedSkill.builder()
|
||
.name(entity.getName())
|
||
.description(description)
|
||
.content(content)
|
||
.source("database")
|
||
.skillDir(null)
|
||
.configuredSkillDir(configuredDir)
|
||
.runtimeAvailable(hasContent || (description != null && !description.isBlank()))
|
||
.resolutionError(error)
|
||
.references(Map.of())
|
||
.scripts(Map.of())
|
||
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
|
||
.icon(entity.getIcon())
|
||
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
|
||
.build();
|
||
}
|
||
|
||
// ==================== 阶段 2:安全扫描 ====================
|
||
|
||
private void applySecurity(ResolvedSkill resolved) {
|
||
try {
|
||
SkillValidationResult result = securityService.validate(resolved);
|
||
boolean trustedBuiltin = resolved.isBuiltin() && result.isBlocked();
|
||
resolved.setSecurityBlocked(result.isBlocked() && !trustedBuiltin);
|
||
resolved.setSecuritySeverity(result.getMaxSeverity() != null ? result.getMaxSeverity().name() : null);
|
||
if (trustedBuiltin) {
|
||
resolved.setSecuritySummary("Builtin skill trusted: " + result.getSummary());
|
||
} else {
|
||
resolved.setSecuritySummary(result.getSummary());
|
||
}
|
||
resolved.setSecurityWarnings(result.getWarnings());
|
||
|
||
// 转换 findings 为 JSON 友好格式
|
||
if (result.getFindings() != null && !result.getFindings().isEmpty()) {
|
||
List<ResolvedSkill.SecurityFinding> secFindings = result.getFindings().stream()
|
||
.map(f -> ResolvedSkill.SecurityFinding.builder()
|
||
.ruleId(f.getRuleId())
|
||
.severity(f.getSeverity() != null ? f.getSeverity().name() : null)
|
||
.category(f.getCategory())
|
||
.title(f.getTitle())
|
||
.description(f.getDescription())
|
||
.filePath(f.getFilePath())
|
||
.lineNumber(f.getLineNumber())
|
||
.snippet(f.getSnippet())
|
||
.remediation(f.getRemediation())
|
||
.build())
|
||
.collect(Collectors.toList());
|
||
resolved.setSecurityFindings(secFindings);
|
||
}
|
||
|
||
if (trustedBuiltin) {
|
||
log.warn("Builtin skill '{}' bypassed security block: {}", resolved.getName(), result.getSummary());
|
||
} else if (result.isBlocked()) {
|
||
log.warn("Skill '{}' blocked by security scan: {}", resolved.getName(), result.getSummary());
|
||
} else if (result.getFindings() != null && !result.getFindings().isEmpty()) {
|
||
log.info("Skill '{}' security scan: {} finding(s)", resolved.getName(), result.getFindings().size());
|
||
}
|
||
} catch (Exception e) {
|
||
log.error("Security scan failed for skill '{}': {}", resolved.getName(), e.getMessage());
|
||
resolved.setSecurityWarnings(List.of("Security scan error: " + e.getMessage()));
|
||
}
|
||
}
|
||
|
||
// ==================== 阶段 3:依赖检查 ====================
|
||
|
||
private void applyDependencyCheck(ResolvedSkill resolved) {
|
||
try {
|
||
// 解析 frontmatter 获取依赖声明
|
||
String content = resolved.getContent();
|
||
if (content == null || content.isBlank()) {
|
||
resolved.setDependencyReady(true);
|
||
resolved.setDependencySummary("No dependencies declared");
|
||
return;
|
||
}
|
||
|
||
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
|
||
SkillFrontmatterParser.SkillDependencies deps = parsed.getDependencies();
|
||
List<String> platforms = parsed.getPlatforms();
|
||
|
||
if ((deps == null || deps.isEmpty()) && (platforms == null || platforms.isEmpty())) {
|
||
resolved.setDependencyReady(true);
|
||
resolved.setDependencySummary("No dependencies declared");
|
||
return;
|
||
}
|
||
|
||
SkillDependencyChecker.DependencyCheckResult result =
|
||
dependencyChecker.check(deps, platforms, resolved.getName());
|
||
|
||
resolved.setDependencyReady(result.isSatisfied());
|
||
resolved.setMissingDependencies(result.getMissing());
|
||
resolved.setDependencySummary(result.getSummary());
|
||
|
||
if (!result.isSatisfied()) {
|
||
log.info("Skill '{}' dependencies not satisfied: {}", resolved.getName(), result.getSummary());
|
||
}
|
||
} catch (Exception e) {
|
||
log.error("Dependency check failed for skill '{}': {}", resolved.getName(), e.getMessage());
|
||
resolved.setDependencyReady(true); // 检查失败不阻断
|
||
resolved.setDependencySummary("Dependency check error: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
// ==================== 阶段 4:综合判定 ====================
|
||
|
||
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
|
||
// 安全阻断 → 不可用
|
||
if (resolved.isSecurityBlocked()) {
|
||
resolved.setRuntimeAvailable(false);
|
||
if (resolved.getResolutionError() == null) {
|
||
resolved.setResolutionError("Security blocked: " + resolved.getSecuritySummary());
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 依赖不满足 → 不可用
|
||
if (!resolved.isDependencyReady()) {
|
||
resolved.setRuntimeAvailable(false);
|
||
if (resolved.getResolutionError() == null) {
|
||
resolved.setResolutionError("Dependencies missing: " + resolved.getDependencySummary());
|
||
}
|
||
}
|
||
|
||
// 其他情况保留原有 runtimeAvailable 判定
|
||
}
|
||
|
||
// ==================== 工具方法 ====================
|
||
|
||
private String extractSkillDirString(SkillEntity entity) {
|
||
String configJson = entity.getConfigJson();
|
||
if (configJson == null || configJson.isBlank()) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
@SuppressWarnings("unchecked")
|
||
Map<String, Object> config = objectMapper.readValue(configJson, Map.class);
|
||
|
||
String pathStr = null;
|
||
if (config.containsKey("skillDir")) {
|
||
pathStr = config.get("skillDir").toString();
|
||
} else if (config.containsKey("path")) {
|
||
pathStr = config.get("path").toString();
|
||
} else if (config.containsKey("directory")) {
|
||
pathStr = config.get("directory").toString();
|
||
}
|
||
|
||
if (pathStr != null && !pathStr.isBlank()) {
|
||
return pathStr;
|
||
}
|
||
} catch (Exception e) {
|
||
log.debug("Failed to parse configJson for skill {}: {}", entity.getName(), e.getMessage());
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|