feat: 增加技能管理模块试验性功能,等待优化

This commit is contained in:
2026-06-08 16:52:41 +08:00
parent 55434466d4
commit 13e848ddf4
29 changed files with 2622 additions and 0 deletions

View File

@@ -0,0 +1,254 @@
package com.easyagents.skill.codec;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.exception.SkillValidationException;
import com.easyagents.skill.factory.SkillFactory;
import com.easyagents.skill.model.*;
import com.easyagents.skill.store.SkillContentStore;
import com.easyagents.skill.store.memory.InMemorySkillContentStore;
import com.easyagents.skill.util.SkillHashes;
import com.easyagents.skill.util.SkillPaths;
import com.easyagents.skill.validation.SkillValidator;
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 基于 zip 的 Skill 包导入实现。
*/
public class ZipSkillPackageCodec implements SkillPackageCodec {
private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
private final SkillContentStore contentStore;
private final SkillValidator validator;
/**
* 创建使用内存内容存储的 zip Skill 包导入器。
*/
public ZipSkillPackageCodec() {
this(new InMemorySkillContentStore());
}
/**
* 创建 zip Skill 包导入器。
*
* @param contentStore 二进制内容存储
*/
public ZipSkillPackageCodec(SkillContentStore contentStore) {
this(contentStore, new DefaultSkillValidator());
}
/**
* 创建 zip Skill 包导入器。
*
* @param contentStore 二进制内容存储
* @param validator Skill 聚合校验器
*/
public ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator) {
if (contentStore == null) {
throw new SkillPackageException("Skill content store is required.");
}
if (validator == null) {
throw new SkillPackageException("Skill validator is required.");
}
this.contentStore = contentStore;
this.validator = validator;
}
/**
* 从 zip 流导入 Skill 列表。
*
* @param inputStream zip 输入流
* @return Skill 列表
*/
@Override
public List<Skill> importZip(InputStream inputStream) {
if (inputStream == null) {
throw new SkillPackageException("Zip input stream is required.");
}
Map<String, SkillBuilder> builders = new LinkedHashMap<>();
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
handleEntry(entry, zipInputStream, builders);
zipInputStream.closeEntry();
}
} catch (IOException e) {
throw new SkillPackageException("Failed to import skill zip package.", e);
}
if (builders.isEmpty()) {
throw new SkillPackageException("Zip package must contain at least one skill folder.");
}
List<Skill> skills = new ArrayList<>();
for (SkillBuilder builder : builders.values()) {
Skill skill = builder.build();
validator.validate(skill);
skills.add(skill);
}
for (SkillBuilder builder : builders.values()) {
builder.writeAssets(contentStore);
}
return skills;
}
private void handleEntry(ZipEntry entry, ZipInputStream zipInputStream, Map<String, SkillBuilder> builders)
throws IOException {
String rawPath = entry.getName();
if (entry.isDirectory() || SkillPaths.isIgnoredSystemPath(rawPath)) {
return;
}
String normalizedPath = SkillPaths.normalize(rawPath);
if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
throw new SkillPackageException("Zip root cannot directly contain SKILL.md.");
}
int slashIndex = normalizedPath.indexOf('/');
if (slashIndex < 0) {
throw new SkillPackageException("Zip root can only contain skill folders: " + normalizedPath);
}
String skillId = normalizedPath.substring(0, slashIndex);
String skillPath = normalizedPath.substring(slashIndex + 1);
if (skillPath.isBlank()) {
return;
}
SkillBuilder builder = builders.computeIfAbsent(skillId, SkillBuilder::new);
builder.addFile(skillPath, readEntryBytes(zipInputStream));
}
private static byte[] readEntryBytes(InputStream inputStream) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int readLength;
while ((readLength = inputStream.read(buffer)) >= 0) {
outputStream.write(buffer, 0, readLength);
}
return outputStream.toByteArray();
}
private static String detectMediaType(String path) {
String mediaType = URLConnection.guessContentTypeFromName(path);
return mediaType == null ? DEFAULT_MEDIA_TYPE : mediaType;
}
private static final class SkillBuilder {
private final String id;
private final Set<String> paths = new HashSet<>();
private String skillContent;
private final List<SkillReference> references = new ArrayList<>();
private final List<SkillScript> scripts = new ArrayList<>();
private final List<SkillAsset> assets = new ArrayList<>();
private final List<PendingAsset> pendingAssets = new ArrayList<>();
private SkillBuilder(String id) {
this.id = id;
}
private void addFile(String path, byte[] bytes) {
String normalizedPath = SkillPaths.normalize(path);
if (!paths.add(normalizedPath)) {
throw new SkillPackageException("Duplicate skill file path: " + id + "/" + normalizedPath);
}
if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
addSkillFile(bytes);
return;
}
String topDir = SkillPaths.firstSegment(normalizedPath);
if (SkillPaths.REFERENCES_DIR.equals(topDir)) {
addReference(normalizedPath, bytes);
} else if (SkillPaths.SCRIPTS_DIR.equals(topDir)) {
addScript(normalizedPath, bytes);
} else if (SkillPaths.ASSETS_DIR.equals(topDir)) {
addAsset(normalizedPath, bytes);
} else {
throw new SkillPackageException("Unknown skill top-level directory: " + id + "/" + normalizedPath);
}
}
private void addSkillFile(byte[] bytes) {
skillContent = new String(bytes, StandardCharsets.UTF_8);
}
private void addReference(String path, byte[] bytes) {
if (!SkillPaths.hasExtension(path, ".md")) {
throw new SkillPackageException("Skill reference must be a markdown file: " + id + "/" + path);
}
String content = new String(bytes, StandardCharsets.UTF_8);
SkillReference reference = new SkillReference();
reference.setPath(path);
reference.setName(SkillPaths.fileName(path));
reference.setContent(content);
reference.setContentHash(SkillHashes.sha256Hex(bytes));
reference.setSize(bytes.length);
references.add(reference);
}
private void addScript(String path, byte[] bytes) {
SkillScriptLanguage language = SkillScriptLanguage.fromPath(path);
if (language == SkillScriptLanguage.UNKNOWN) {
throw new SkillPackageException("Unsupported skill script extension: " + id + "/" + path);
}
SkillScript script = new SkillScript();
script.setPath(path);
script.setLanguage(language);
script.setContent(new String(bytes, StandardCharsets.UTF_8));
script.setContentHash(SkillHashes.sha256Hex(bytes));
script.setSize(bytes.length);
scripts.add(script);
}
private void addAsset(String path, byte[] bytes) {
String contentHash = SkillHashes.sha256Hex(bytes);
SkillAsset asset = new SkillAsset();
asset.setPath(path);
asset.setName(SkillPaths.fileName(path));
asset.setMediaType(detectMediaType(path));
asset.setContentRef("sha256:" + contentHash);
asset.setContentHash(contentHash);
asset.setSize(bytes.length);
assets.add(asset);
pendingAssets.add(new PendingAsset(asset, bytes));
}
private Skill build() {
if (skillContent == null) {
throw new SkillPackageException("Skill folder must contain SKILL.md: " + id);
}
try {
return SkillFactory.create(id, skillContent, references, scripts, assets);
} catch (SkillValidationException e) {
throw new SkillPackageException("Invalid SKILL.md frontmatter: " + id, e);
}
}
private void writeAssets(SkillContentStore contentStore) {
for (PendingAsset pendingAsset : pendingAssets) {
String contentRef = contentStore.put(pendingAsset.bytes);
if (!pendingAsset.asset.getContentRef().equals(contentRef)) {
throw new SkillPackageException("Skill asset content ref does not match store result: "
+ id + "/" + pendingAsset.asset.getPath());
}
}
}
}
private static final class PendingAsset {
private final SkillAsset asset;
private final byte[] bytes;
private PendingAsset(SkillAsset asset, byte[] bytes) {
this.asset = asset;
this.bytes = bytes;
}
}
}