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
194 lines
7.5 KiB
Java
194 lines
7.5 KiB
Java
package vip.mate.skill.installer;
|
|
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.stereotype.Service;
|
|
import vip.mate.skill.installer.model.HubSkillInfo;
|
|
import vip.mate.skill.installer.model.SkillBundle;
|
|
|
|
import java.net.URI;
|
|
import java.net.http.HttpClient;
|
|
import java.net.http.HttpRequest;
|
|
import java.net.http.HttpResponse;
|
|
import java.time.Duration;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* ClawHub 市场 API 客户端
|
|
* <p>
|
|
* 提供 skill 搜索和 bundle 获取能力。
|
|
*
|
|
* @author MateClaw Team
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
public class SkillHubClient {
|
|
|
|
private final SkillHubProperties properties;
|
|
private final ObjectMapper objectMapper;
|
|
private final HttpClient httpClient;
|
|
|
|
public SkillHubClient(SkillHubProperties properties, ObjectMapper objectMapper) {
|
|
this.properties = properties;
|
|
this.objectMapper = objectMapper;
|
|
this.httpClient = HttpClient.newBuilder()
|
|
.connectTimeout(Duration.ofSeconds(properties.getHttpTimeout()))
|
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
|
.build();
|
|
}
|
|
|
|
/**
|
|
* 搜索 ClawHub 市场
|
|
*/
|
|
public List<HubSkillInfo> search(String query, int limit) {
|
|
String url = properties.getBaseUrl() + properties.getSearchPath()
|
|
+ "?q=" + encodeParam(query) + "&limit=" + limit;
|
|
|
|
for (int attempt = 0; attempt <= properties.getHttpRetries(); attempt++) {
|
|
try {
|
|
HttpRequest request = HttpRequest.newBuilder()
|
|
.uri(URI.create(url))
|
|
.timeout(Duration.ofSeconds(properties.getHttpTimeout()))
|
|
.GET()
|
|
.header("Accept", "application/json")
|
|
.header("User-Agent", "MateClaw/1.0")
|
|
.build();
|
|
|
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
|
|
|
if (response.statusCode() == 200) {
|
|
return parseSearchResponse(response.body());
|
|
}
|
|
|
|
if (isRetryable(response.statusCode()) && attempt < properties.getHttpRetries()) {
|
|
log.warn("Hub search attempt {} failed with status {}, retrying...", attempt + 1, response.statusCode());
|
|
Thread.sleep(backoffMs(attempt));
|
|
continue;
|
|
}
|
|
|
|
log.warn("Hub search failed with status {}: {}", response.statusCode(), response.body());
|
|
return Collections.emptyList();
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
return Collections.emptyList();
|
|
} catch (Exception e) {
|
|
if (attempt < properties.getHttpRetries()) {
|
|
log.warn("Hub search attempt {} error: {}, retrying...", attempt + 1, e.getMessage());
|
|
try {
|
|
Thread.sleep(backoffMs(attempt));
|
|
} catch (InterruptedException ie) {
|
|
Thread.currentThread().interrupt();
|
|
return Collections.emptyList();
|
|
}
|
|
} else {
|
|
log.error("Hub search failed after {} attempts: {}", properties.getHttpRetries() + 1, e.getMessage());
|
|
}
|
|
}
|
|
}
|
|
return Collections.emptyList();
|
|
}
|
|
|
|
/**
|
|
* 获取 skill bundle 详情
|
|
*/
|
|
public SkillBundle fetchBundle(String slug, String version) {
|
|
String path = version != null && !version.isBlank()
|
|
? "/api/v1/skills/" + slug + "/versions/" + encodeParam(version)
|
|
: "/api/v1/skills/" + slug;
|
|
String url = properties.getBaseUrl() + path;
|
|
|
|
try {
|
|
HttpRequest request = HttpRequest.newBuilder()
|
|
.uri(URI.create(url))
|
|
.timeout(Duration.ofSeconds(properties.getHttpTimeout()))
|
|
.GET()
|
|
.header("Accept", "application/json")
|
|
.header("User-Agent", "MateClaw/1.0")
|
|
.build();
|
|
|
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
|
|
|
if (response.statusCode() == 200) {
|
|
return parseBundleResponse(response.body(), slug);
|
|
}
|
|
|
|
log.warn("Hub fetchBundle failed for '{}': status {}", slug, response.statusCode());
|
|
return null;
|
|
} catch (Exception e) {
|
|
log.error("Hub fetchBundle error for '{}': {}", slug, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ==================== 内部方法 ====================
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private List<HubSkillInfo> parseSearchResponse(String body) {
|
|
try {
|
|
Map<String, Object> json = objectMapper.readValue(body, new TypeReference<>() {});
|
|
Object data = json.get("data");
|
|
if (data == null) {
|
|
data = json.get("results");
|
|
}
|
|
if (data == null) {
|
|
data = json.get("skills");
|
|
}
|
|
if (data instanceof List<?> list) {
|
|
String jsonStr = objectMapper.writeValueAsString(list);
|
|
return objectMapper.readValue(jsonStr, new TypeReference<>() {});
|
|
}
|
|
return Collections.emptyList();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to parse hub search response: {}", e.getMessage());
|
|
return Collections.emptyList();
|
|
}
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private SkillBundle parseBundleResponse(String body, String slug) {
|
|
try {
|
|
Map<String, Object> json = objectMapper.readValue(body, new TypeReference<>() {});
|
|
String name = getStr(json, "name", slug);
|
|
String content = getStr(json, "content", "");
|
|
String description = getStr(json, "description", "");
|
|
String author = getStr(json, "author", "");
|
|
String version = getStr(json, "version", "1.0.0");
|
|
String icon = getStr(json, "icon", "");
|
|
|
|
Map<String, String> references = json.containsKey("references")
|
|
? objectMapper.convertValue(json.get("references"), new TypeReference<>() {})
|
|
: Map.of();
|
|
Map<String, String> scripts = json.containsKey("scripts")
|
|
? objectMapper.convertValue(json.get("scripts"), new TypeReference<>() {})
|
|
: Map.of();
|
|
|
|
return new SkillBundle(name, content, references, scripts,
|
|
"clawhub", properties.getBaseUrl() + "/skills/" + slug,
|
|
version, description, author, icon);
|
|
} catch (Exception e) {
|
|
log.warn("Failed to parse hub bundle response: {}", e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private String getStr(Map<String, Object> map, String key, String defaultVal) {
|
|
Object v = map.get(key);
|
|
return v != null ? v.toString() : defaultVal;
|
|
}
|
|
|
|
private boolean isRetryable(int statusCode) {
|
|
return statusCode == 408 || statusCode == 429 || statusCode >= 500;
|
|
}
|
|
|
|
private long backoffMs(int attempt) {
|
|
return (long) (800 * Math.pow(2, attempt));
|
|
}
|
|
|
|
private String encodeParam(String value) {
|
|
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
|
|
}
|
|
}
|