mateclaw/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java

344 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.installer;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import vip.mate.skill.installer.model.*;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceEvent;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import org.springframework.context.ApplicationEventPublisher;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* Skill 安装核心服务
* <p>
* 管理从外部源GitHub / ClawHub安装 skill 的完整流程:
* URL 解析 → bundle 获取 → workspace 落盘 → 数据库注册 → 运行时刷新。
* <p>
* 支持异步安装task_id 轮询模式),参考 MateClaw 实现。
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SkillInstaller {
private final BundleResolver bundleResolver;
private final SkillHubClient skillHubClient;
private final SkillWorkspaceManager workspaceManager;
private final SkillService skillService;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
/** 安装任务追踪 */
private final ConcurrentHashMap<String, InstallTask> tasks = new ConcurrentHashMap<>();
/**
* 启动异步安装任务
*/
public InstallTask startInstall(InstallRequest request) {
String taskId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
InstallTask task = InstallTask.create(taskId, request.getBundleUrl());
tasks.put(taskId, task);
doInstallAsync(taskId, request);
return task;
}
/**
* 获取安装任务状态
*/
public InstallTask getTaskStatus(String taskId) {
return tasks.get(taskId);
}
/**
* 取消安装任务
*/
public void cancelTask(String taskId) {
InstallTask task = tasks.get(taskId);
if (task != null && task.getStatus() == InstallTask.InstallStatus.INSTALLING) {
task.setCancelRequested(true);
task.markCancelled();
log.info("Install task {} cancelled", taskId);
}
}
/**
* 卸载 skill归档 workspace + 删除数据库记录)
*/
public void uninstall(String skillName) {
// 先在数据库中查找
List<SkillEntity> skills = skillService.listSkills();
SkillEntity target = skills.stream()
.filter(s -> s.getName().equals(skillName))
.findFirst()
.orElse(null);
if (target != null) {
skillService.deleteSkill(target.getId());
}
// workspace 归档已在 SkillService.deleteSkill 中处理
log.info("Uninstalled skill: {}", skillName);
}
/**
* 搜索 ClawHub 市场(委托给 SkillHubClient
*/
public List<HubSkillInfo> searchHub(String query, int limit) {
try {
return skillHubClient.search(query, limit);
} catch (Exception e) {
log.warn("Hub search failed: {}", e.getMessage());
return Collections.emptyList();
}
}
// ==================== 异步安装流程 ====================
@Async
public CompletableFuture<Void> doInstallAsync(String taskId, InstallRequest request) {
InstallTask task = tasks.get(taskId);
if (task == null) return CompletableFuture.completedFuture(null);
task.markInstalling();
try {
// 1. 解析 bundle
SkillBundle bundle = bundleResolver.resolve(request.getBundleUrl(), request.getVersion());
if (bundle == null) {
task.markFailed("Failed to resolve skill bundle from: " + request.getBundleUrl());
return CompletableFuture.completedFuture(null);
}
if (task.isCancelRequested()) {
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 2. 确定 skill 名称
String skillName = request.getTargetName() != null ? request.getTargetName() : bundle.name();
if (skillName == null || skillName.isBlank()) {
task.markFailed("Cannot determine skill name from bundle");
return CompletableFuture.completedFuture(null);
}
// 3. 检查是否已存在
boolean exists = skillService.listSkills().stream()
.anyMatch(s -> s.getName().equals(skillName));
if (exists && !Boolean.TRUE.equals(request.getOverwrite())) {
task.markFailed("Skill '" + skillName + "' already exists. Set overwrite=true to replace.");
return CompletableFuture.completedFuture(null);
}
if (task.isCancelRequested()) {
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 4. 写入 workspace 目录
// overwrite 时先清理旧 references/ 和 scripts/,防止残留过期文件
if (exists) {
workspaceManager.cleanWorkspaceDataDirs(skillName);
}
workspaceManager.initWorkspace(skillName, bundle.content());
// 写入 references/
if (bundle.references() != null) {
for (var entry : bundle.references().entrySet()) {
workspaceManager.writeWorkspaceFile(skillName, "references/" + entry.getKey(), entry.getValue());
}
}
// 写入 scripts/
if (bundle.scripts() != null) {
for (var entry : bundle.scripts().entrySet()) {
workspaceManager.writeWorkspaceFile(skillName, "scripts/" + entry.getKey(), entry.getValue());
}
}
// cancel check: 文件已落盘,但数据库尚未写入 —— 归档已写入的目录后退出
if (task.isCancelRequested()) {
workspaceManager.archiveWorkspace(skillName);
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 5. 注册/更新数据库
SkillEntity skillEntity;
if (exists) {
// 更新已有记录
skillEntity = skillService.listSkills().stream()
.filter(s -> s.getName().equals(skillName))
.findFirst().orElseThrow();
skillEntity.setSkillContent(bundle.content());
skillEntity.setDescription(bundle.description());
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setConfigJson(buildConfigJson(bundle));
if (Boolean.TRUE.equals(request.getEnable())) {
skillEntity.setEnabled(true);
}
skillService.updateSkill(skillEntity);
} else {
// 创建新记录
skillEntity = new SkillEntity();
skillEntity.setName(skillName);
skillEntity.setDescription(bundle.description());
skillEntity.setSkillType("dynamic");
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setSkillContent(bundle.content());
skillEntity.setConfigJson(buildConfigJson(bundle));
skillEntity.setEnabled(Boolean.TRUE.equals(request.getEnable()));
skillService.createSkill(skillEntity);
}
// cancel check: DB 已写入,此时取消不再回滚数据库,但标记任务为 cancelled
if (task.isCancelRequested()) {
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 6. 发布事件
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
// 7. 完成
task.markCompleted(InstallResult.builder()
.name(skillName)
.enabled(Boolean.TRUE.equals(request.getEnable()))
.sourceUrl(bundle.sourceUrl())
.sourceType(bundle.sourceType())
.build());
log.info("Skill '{}' installed successfully from {}", skillName, bundle.sourceUrl());
} catch (Exception e) {
log.error("Install task {} failed: {}", taskId, e.getMessage(), e);
task.markFailed(e.getMessage());
}
return CompletableFuture.completedFuture(null);
}
/**
* 同步安装 SkillBundle用于 ZIP 上传等本地解析场景,无需异步任务)
*
* @return 安装结果 MapskillId, name, version, filesCount
*/
public Map<String, Object> installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, String targetName) {
String skillName = (targetName != null && !targetName.isBlank()) ? targetName : bundle.name();
if (skillName == null || skillName.isBlank()) {
throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle");
}
boolean exists = skillService.listSkills().stream()
.anyMatch(s -> s.getName().equals(skillName));
if (exists && !overwrite) {
throw new vip.mate.exception.MateClawException("err.skill.name_exists",
"Skill '" + skillName + "' already exists. Enable overwrite to replace.");
}
// 写入 workspace
if (exists) {
workspaceManager.cleanWorkspaceDataDirs(skillName);
}
workspaceManager.initWorkspace(skillName, bundle.content());
if (bundle.references() != null) {
for (var entry : bundle.references().entrySet()) {
String key = entry.getKey();
if (!key.startsWith("references/")) key = "references/" + key;
workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue());
}
}
if (bundle.scripts() != null) {
for (var entry : bundle.scripts().entrySet()) {
String key = entry.getKey();
if (!key.startsWith("scripts/")) key = "scripts/" + key;
workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue());
}
}
// 注册/更新 DB
SkillEntity skillEntity;
if (exists) {
skillEntity = skillService.listSkills().stream()
.filter(s -> s.getName().equals(skillName))
.findFirst().orElseThrow();
skillEntity.setSkillContent(bundle.content());
skillEntity.setDescription(bundle.description());
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setConfigJson(buildConfigJson(bundle));
if (enable) skillEntity.setEnabled(true);
skillService.updateSkill(skillEntity);
} else {
skillEntity = new SkillEntity();
skillEntity.setName(skillName);
skillEntity.setDescription(bundle.description());
skillEntity.setSkillType("dynamic");
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setSkillContent(bundle.content());
skillEntity.setConfigJson(buildConfigJson(bundle));
skillEntity.setEnabled(enable);
skillService.createSkill(skillEntity);
}
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
int filesCount = (bundle.references() != null ? bundle.references().size() : 0)
+ (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1;
log.info("Skill '{}' installed from ZIP (v{}, {} files)", skillName, bundle.version(), filesCount);
return Map.of(
"skillId", skillEntity.getId(),
"name", skillName,
"version", bundle.version() != null ? bundle.version() : "",
"filesCount", filesCount
);
}
// ==================== 工具方法 ====================
private String buildConfigJson(SkillBundle bundle) {
try {
Map<String, Object> config = new LinkedHashMap<>();
config.put("upstream", bundle.sourceType());
config.put("entryFile", "SKILL.md");
Map<String, Object> source = new LinkedHashMap<>();
source.put("type", bundle.sourceType());
source.put("url", bundle.sourceUrl());
source.put("installedAt", LocalDateTime.now().toString());
source.put("installedVersion", bundle.version());
config.put("source", source);
return objectMapper.writeValueAsString(config);
} catch (Exception e) {
return "{\"upstream\":\"" + bundle.sourceType() + "\"}";
}
}
}