feat: 增加技能管理模块试验性功能,等待优化
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user