mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): bundle file management — view and edit scripts, references, and templates from the console
The skill detail drawer could only show and edit SKILL.md; the bundle
files under scripts/ and references/ had no console surface, and
templates/ was readable by agents but absent from the canonical
store's bucket set.
- admin endpoints on /api/v1/skills/{id}/files: list (self-heals an
empty canonical store from on-disk files), read, upsert, delete.
Writes update the canonical row, materialize the workspace cache,
and re-resolve the skill so agents pick changes up immediately.
Path envelope enforces the three buckets and blocks traversal;
builtin skill files stay read-only; virtual skills own no files.
- templates/ becomes a first-class DB-persisted bucket shared across
the syncer, the workspace write/delete envelope, and prune guards.
- the agent-facing write_file action now mirrors into the canonical
store and re-resolves instead of writing only the local filesystem.
- SkillMarket detail drawer gains a Files tab: grouped list, viewer,
inline editor, create and delete, refetched on every entry.
This commit is contained in:
parent
aa0e249ce4
commit
4ac72a90a0
@ -1010,7 +1010,12 @@ public class ChannelMessageRouter {
|
||||
case STATUS -> buildStatusReply(channelEntity, conversationId);
|
||||
};
|
||||
if (replyTarget != null && reply != null) {
|
||||
adapter.sendMessage(replyTarget, reply);
|
||||
// renderAndSend (not sendMessage) so adapters that pre-post a
|
||||
// "thinking..." placeholder on inbound (WeCom reply_stream)
|
||||
// consume it here: the confirmation overwrites the placeholder
|
||||
// bubble in place and the keepalive refresher is stopped.
|
||||
// Plain sendMessage would leave the placeholder dangling forever.
|
||||
adapter.renderAndSend(replyTarget, reply);
|
||||
}
|
||||
log.info("[{}] Magic command handled: {} conversationId={}, sender={}",
|
||||
adapter.getChannelType(), command.type(), conversationId,
|
||||
|
||||
@ -20,6 +20,8 @@ import vip.mate.skill.runtime.SkillDependencyChecker;
|
||||
import vip.mate.skill.runtime.SkillPackageResolver;
|
||||
import vip.mate.skill.runtime.SkillCatalogSort;
|
||||
import vip.mate.skill.runtime.SkillCatalogSorter;
|
||||
import vip.mate.skill.model.SkillFileEntity;
|
||||
import vip.mate.skill.service.SkillFileService;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.synthesis.SkillSynthesisService;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
@ -27,6 +29,7 @@ import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.skill.workspace.BundledSkillSyncer;
|
||||
import vip.mate.skill.workspace.SkillFileSyncer;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
import vip.mate.skill.workspace.bundle.SkillBundleFiles;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.lifecycle.ConfirmRequiredException;
|
||||
import vip.mate.skill.lifecycle.LifecycleTransition;
|
||||
@ -73,6 +76,7 @@ public class SkillController {
|
||||
private final SkillLifecycleService skillLifecycleService;
|
||||
private final SkillCuratorJob skillCuratorJob;
|
||||
private final SkillCuratorReportStore skillCuratorReportStore;
|
||||
private final SkillFileService skillFileService;
|
||||
|
||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||
@GetMapping
|
||||
@ -336,6 +340,157 @@ public class SkillController {
|
||||
return R.ok(body);
|
||||
}
|
||||
|
||||
// ==================== Bundle files (scripts/ + references/) ====================
|
||||
|
||||
/** Per-file content ceiling for the admin editor — matches the installer's per-file bound. */
|
||||
private static final int MAX_BUNDLE_FILE_CHARS = 1_000_000;
|
||||
|
||||
@Operation(summary = "List a skill's bundle files (scripts/ + references/), without content")
|
||||
@GetMapping("/{id}/files")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<Map<String, Object>>> listBundleFiles(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
// Virtual MCP/ACP skills mirror live servers and own no bundle files.
|
||||
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)
|
||||
|| vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
|
||||
return R.ok(List.of());
|
||||
}
|
||||
SkillEntity skill = skillService.getSkill(id);
|
||||
verifyResourceWorkspace(skill, workspaceId);
|
||||
List<SkillFileEntity> rows = skillFileService.listBySkillId(id);
|
||||
if (rows.isEmpty()) {
|
||||
// Self-heal: ingest pre-canonical on-disk files into the DB store
|
||||
// so legacy directory-based skills list their files too.
|
||||
skillFileSyncer.syncOne(skill);
|
||||
rows = skillFileService.listBySkillId(id);
|
||||
}
|
||||
List<Map<String, Object>> out = new ArrayList<>(rows.size());
|
||||
rows.stream()
|
||||
.sorted(java.util.Comparator.comparing(SkillFileEntity::getFilePath,
|
||||
java.util.Comparator.nullsLast(String::compareTo)))
|
||||
.forEach(row -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("path", row.getFilePath());
|
||||
item.put("size", row.getContentSize());
|
||||
item.put("sha256", row.getSha256());
|
||||
item.put("updateTime", row.getUpdateTime());
|
||||
out.add(item);
|
||||
});
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
@Operation(summary = "Read one bundle file's content")
|
||||
@GetMapping("/{id}/files/content")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Map<String, Object>> getBundleFileContent(@PathVariable Long id,
|
||||
@RequestParam String path,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
SkillEntity skill = skillService.getSkill(id);
|
||||
verifyResourceWorkspace(skill, workspaceId);
|
||||
String normalized = normalizeBundlePath(path);
|
||||
if (normalized == null) {
|
||||
return R.fail("Invalid file path: " + path);
|
||||
}
|
||||
SkillFileEntity row = skillFileService.getFile(id, normalized);
|
||||
if (row == null) {
|
||||
return R.fail("File not found: " + normalized);
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("path", row.getFilePath());
|
||||
body.put("content", row.getContent() == null ? "" : row.getContent());
|
||||
body.put("size", row.getContentSize());
|
||||
body.put("sha256", row.getSha256());
|
||||
body.put("updateTime", row.getUpdateTime());
|
||||
return R.ok(body);
|
||||
}
|
||||
|
||||
@Operation(summary = "Create or update one bundle file",
|
||||
description = "Writes the canonical mate_skill_file row, materializes the workspace cache, " +
|
||||
"and re-resolves the skill so the runtime file tree updates immediately.")
|
||||
@PutMapping("/{id}/files/content")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> putBundleFileContent(@PathVariable Long id,
|
||||
@RequestBody Map<String, String> body,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
rejectVirtualSkillMutation(id);
|
||||
SkillEntity skill = skillService.getSkill(id);
|
||||
verifyResourceWorkspace(skill, workspaceId);
|
||||
if (Boolean.TRUE.equals(skill.getBuiltin())) {
|
||||
return R.fail("Builtin skill files are read-only — they are restored from the shipped bundle on upgrade.");
|
||||
}
|
||||
String normalized = normalizeBundlePath(body.get("path"));
|
||||
if (normalized == null) {
|
||||
return R.fail("Invalid file path — must be under scripts/, references/ or templates/, no '..'.");
|
||||
}
|
||||
String content = body.get("content");
|
||||
if (content == null) {
|
||||
return R.fail("content is required (use the delete endpoint to remove a file).");
|
||||
}
|
||||
if (content.length() > MAX_BUNDLE_FILE_CHARS) {
|
||||
return R.fail("Content too large (" + content.length() + " chars, max " + MAX_BUNDLE_FILE_CHARS + ").");
|
||||
}
|
||||
|
||||
SkillFileEntity row = skillFileService.upsertFile(id, normalized, content);
|
||||
try {
|
||||
workspaceManager.writeWorkspaceFile(skill.getName(), normalized, content);
|
||||
} catch (Exception e) {
|
||||
// Canonical store is updated; the syncer heals the cache later.
|
||||
}
|
||||
skillRuntimeService.rescanSingle(skill);
|
||||
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("path", row.getFilePath());
|
||||
out.put("size", row.getContentSize());
|
||||
out.put("sha256", row.getSha256());
|
||||
out.put("updateTime", row.getUpdateTime());
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
@Operation(summary = "Delete one bundle file")
|
||||
@DeleteMapping("/{id}/files")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> deleteBundleFile(@PathVariable Long id,
|
||||
@RequestParam String path,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
rejectVirtualSkillMutation(id);
|
||||
SkillEntity skill = skillService.getSkill(id);
|
||||
verifyResourceWorkspace(skill, workspaceId);
|
||||
if (Boolean.TRUE.equals(skill.getBuiltin())) {
|
||||
return R.fail("Builtin skill files are read-only — they are restored from the shipped bundle on upgrade.");
|
||||
}
|
||||
String normalized = normalizeBundlePath(path);
|
||||
if (normalized == null) {
|
||||
return R.fail("Invalid file path: " + path);
|
||||
}
|
||||
boolean removed = skillFileService.deleteFile(id, normalized);
|
||||
try {
|
||||
workspaceManager.deleteWorkspaceFile(skill.getName(), normalized);
|
||||
} catch (Exception e) {
|
||||
// Cache cleanup is best-effort; the canonical row is gone.
|
||||
}
|
||||
skillRuntimeService.rescanSingle(skill);
|
||||
return R.ok(Map.of("path", normalized, "removed", removed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a bundle-relative path and enforce the same envelope the
|
||||
* store and workspace cache use: forward slashes, must sit under a
|
||||
* DB-persisted bucket ({@code scripts/}, {@code references/} or
|
||||
* {@code templates/}), no traversal, no absolute paths, and a
|
||||
* non-empty file name.
|
||||
*
|
||||
* @return normalized path, or {@code null} when rejected
|
||||
*/
|
||||
static String normalizeBundlePath(String path) {
|
||||
if (path == null || path.isBlank()) return null;
|
||||
String p = path.strip().replace('\\', '/');
|
||||
if (p.startsWith("/") || p.contains("..") || p.contains("//") || p.endsWith("/")) return null;
|
||||
if (!SkillBundleFiles.isDbEligible(p)) return null;
|
||||
int slash = p.indexOf('/');
|
||||
if (slash == p.length() - 1) return null;
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge
|
||||
* synthesizes those rows on the fly from the upstream connection
|
||||
|
||||
@ -80,13 +80,16 @@ public class SkillFileService {
|
||||
Map<String, String> incoming = newFiles == null ? Map.of() : newFiles;
|
||||
boolean newHasScripts = bucketHasEntries(incoming, "scripts/");
|
||||
boolean newHasRefs = bucketHasEntries(incoming, "references/");
|
||||
boolean newHasTemplates = bucketHasEntries(incoming, "templates/");
|
||||
|
||||
List<SkillFileEntity> existing = listBySkillId(skillId);
|
||||
boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/"));
|
||||
boolean existingHasRefs = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("references/"));
|
||||
boolean existingHasTemplates = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("templates/"));
|
||||
|
||||
boolean preserveScripts = !newHasScripts && existingHasScripts && !force;
|
||||
boolean preserveRefs = !newHasRefs && existingHasRefs && !force;
|
||||
boolean preserveTemplates = !newHasTemplates && existingHasTemplates && !force;
|
||||
|
||||
Map<String, SkillFileEntity> existingByPath = new HashMap<>();
|
||||
for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e);
|
||||
@ -106,6 +109,13 @@ public class SkillFileService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preserveTemplates) {
|
||||
for (SkillFileEntity e : existing) {
|
||||
if (e.getFilePath() != null && e.getFilePath().startsWith("templates/")) {
|
||||
keepPaths.add(e.getFilePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
keepPaths.addAll(incoming.keySet());
|
||||
|
||||
int written = 0;
|
||||
@ -152,6 +162,9 @@ public class SkillFileService {
|
||||
if (preserveRefs) {
|
||||
log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
|
||||
}
|
||||
if (preserveTemplates) {
|
||||
log.warn("Refused to prune templates/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
|
||||
}
|
||||
|
||||
return new ApplyResult(written, pruned, preserveScripts, preserveRefs);
|
||||
}
|
||||
@ -163,6 +176,61 @@ public class SkillFileService {
|
||||
return mapper.deleteBySkillId(skillId);
|
||||
}
|
||||
|
||||
/** One file row by exact path, or {@code null}. */
|
||||
public SkillFileEntity getFile(Long skillId, String filePath) {
|
||||
if (skillId == null || filePath == null || filePath.isBlank()) return null;
|
||||
QueryWrapper<SkillFileEntity> q = new QueryWrapper<>();
|
||||
q.eq("skill_id", skillId).eq("file_path", filePath);
|
||||
return mapper.selectOne(q);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a single file row. Content hash and size are
|
||||
* recomputed; a same-hash write is a no-op so idempotent saves don't
|
||||
* churn {@code update_time}.
|
||||
*
|
||||
* @return the persisted row (existing row instance on no-op)
|
||||
*/
|
||||
@Transactional
|
||||
public SkillFileEntity upsertFile(Long skillId, String filePath, String content) {
|
||||
String safeContent = content == null ? "" : content;
|
||||
String hash = sha256Hex(safeContent);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
SkillFileEntity existing = getFile(skillId, filePath);
|
||||
if (existing != null) {
|
||||
if (hash.equals(existing.getSha256())) {
|
||||
return existing;
|
||||
}
|
||||
existing.setContent(safeContent);
|
||||
existing.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length);
|
||||
existing.setSha256(hash);
|
||||
existing.setUpdateTime(now);
|
||||
mapper.updateById(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
SkillFileEntity row = new SkillFileEntity();
|
||||
row.setSkillId(skillId);
|
||||
row.setFilePath(filePath);
|
||||
row.setContent(safeContent);
|
||||
row.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length);
|
||||
row.setSha256(hash);
|
||||
row.setCreateTime(now);
|
||||
row.setUpdateTime(now);
|
||||
mapper.insert(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Delete a single file row. Returns {@code true} when a row was removed. */
|
||||
@Transactional
|
||||
public boolean deleteFile(Long skillId, String filePath) {
|
||||
SkillFileEntity existing = getFile(skillId, filePath);
|
||||
if (existing == null) return false;
|
||||
mapper.deleteById(existing.getId());
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean bucketHasEntries(Map<String, String> files, String prefix) {
|
||||
for (String key : files.keySet()) {
|
||||
if (key != null && key.startsWith(prefix)) return true;
|
||||
|
||||
@ -27,8 +27,8 @@ import java.util.Set;
|
||||
|
||||
/**
|
||||
* Mirrors canonical {@code mate_skill_file} rows down to each node's local
|
||||
* workspace cache so {@code scripts/} and {@code references/} files exist
|
||||
* on disk wherever the skill might run.
|
||||
* workspace cache so {@code scripts/}, {@code references/} and
|
||||
* {@code templates/} files exist on disk wherever the skill might run.
|
||||
*
|
||||
* <p>Also runs a one-time backfill: for any skill that has on-disk files
|
||||
* but no DB rows (typically pre-V112 installs), the local files are read
|
||||
@ -166,9 +166,9 @@ public class SkillFileSyncer {
|
||||
private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) {
|
||||
String relative = row.getFilePath();
|
||||
if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED;
|
||||
if (!relative.startsWith("references/") && !relative.startsWith("scripts/")) {
|
||||
log.warn("Skipping skill_file row {} — path outside scripts/ or references/: {}",
|
||||
row.getId(), relative);
|
||||
if (!SkillBundleFiles.isDbEligible(relative)) {
|
||||
log.warn("Skipping skill_file row {} — path outside the DB-persisted buckets ({}): {}",
|
||||
row.getId(), SkillBundleFiles.DB_BUCKET_PREFIXES, relative);
|
||||
return MaterializeOutcome.SKIPPED;
|
||||
}
|
||||
if (relative.contains("..")) {
|
||||
@ -208,11 +208,11 @@ public class SkillFileSyncer {
|
||||
private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) {
|
||||
if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0;
|
||||
|
||||
List<Path> roots = new ArrayList<>(2);
|
||||
Path scripts = workspaceDir.resolve("scripts");
|
||||
Path references = workspaceDir.resolve("references");
|
||||
if (Files.isDirectory(scripts)) roots.add(scripts);
|
||||
if (Files.isDirectory(references)) roots.add(references);
|
||||
List<Path> roots = new ArrayList<>(SkillBundleFiles.DB_BUCKET_PREFIXES.size());
|
||||
for (String prefix : SkillBundleFiles.DB_BUCKET_PREFIXES) {
|
||||
Path root = workspaceDir.resolve(prefix.substring(0, prefix.length() - 1));
|
||||
if (Files.isDirectory(root)) roots.add(root);
|
||||
}
|
||||
if (roots.isEmpty()) return 0;
|
||||
|
||||
Map<String, String> ingested = new LinkedHashMap<>();
|
||||
|
||||
@ -356,6 +356,40 @@ public class SkillWorkspaceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 skill 工作区删除单个文件(与 {@link #writeWorkspaceFile} 相同的
|
||||
* 路径安全约束)。文件不存在时为 no-op。顺带清理删空的父目录,
|
||||
* 避免目录树里残留空壳。
|
||||
*
|
||||
* @throws IllegalArgumentException 如果路径不安全
|
||||
*/
|
||||
public void deleteWorkspaceFile(String skillName, String relativePath) {
|
||||
Path workspaceDir = resolveConventionPath(skillName);
|
||||
Path safePath = validateWritePath(workspaceDir, relativePath);
|
||||
if (safePath == null) {
|
||||
throw new IllegalArgumentException("Unsafe file path rejected: " + relativePath);
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(safePath);
|
||||
// Prune emptied parent dirs up to (not including) the bucket root.
|
||||
Path parent = safePath.getParent();
|
||||
Path scriptsRoot = workspaceDir.resolve("scripts");
|
||||
Path referencesRoot = workspaceDir.resolve("references");
|
||||
Path templatesRoot = workspaceDir.resolve("templates");
|
||||
while (parent != null && !parent.equals(scriptsRoot) && !parent.equals(referencesRoot)
|
||||
&& !parent.equals(templatesRoot)
|
||||
&& parent.startsWith(workspaceDir) && !parent.equals(workspaceDir)) {
|
||||
try (var children = Files.list(parent)) {
|
||||
if (children.findAny().isPresent()) break;
|
||||
}
|
||||
Files.delete(parent);
|
||||
parent = parent.getParent();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete workspace file {}/{}: {}", skillName, relativePath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空 skill 工作区中的 references/ 和 scripts/ 目录内容(保留目录本身)
|
||||
* 用于 overwrite 安装前清除旧版本残留文件
|
||||
@ -531,8 +565,9 @@ public class SkillWorkspaceManager {
|
||||
// 归一化分隔符
|
||||
String normalized = relativePath.replace("\\", "/");
|
||||
|
||||
// 必须以 references/ 或 scripts/ 开头
|
||||
if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) {
|
||||
// 必须以 references/、scripts/ 或 templates/ 开头
|
||||
if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")
|
||||
&& !normalized.startsWith("templates/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -9,16 +9,17 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* Helpers for the bundle file buckets that mirror into the canonical
|
||||
* {@code mate_skill_file} store ({@code scripts/} and {@code references/}).
|
||||
* {@code mate_skill_file} store ({@code scripts/}, {@code references/}
|
||||
* and {@code templates/}).
|
||||
*
|
||||
* <p>Shared by the bundled-skill startup sync and the skill file syncer's
|
||||
* classpath backfill so both agree on which paths are DB-persisted and how
|
||||
* bundle contents are read into memory.
|
||||
* <p>Shared by the bundled-skill startup sync, the skill file syncer's
|
||||
* classpath backfill, and the admin file editor so all agree on which
|
||||
* paths are DB-persisted and how bundle contents are read into memory.
|
||||
*/
|
||||
public final class SkillBundleFiles {
|
||||
|
||||
/** Path prefixes of the buckets persisted to {@code mate_skill_file}. */
|
||||
public static final List<String> DB_BUCKET_PREFIXES = List.of("scripts/", "references/");
|
||||
public static final List<String> DB_BUCKET_PREFIXES = List.of("scripts/", "references/", "templates/");
|
||||
|
||||
private SkillBundleFiles() {
|
||||
}
|
||||
@ -33,7 +34,7 @@ public final class SkillBundleFiles {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every {@code scripts/} and {@code references/} file of the bundle
|
||||
* Read every DB-eligible bundle file ({@link #DB_BUCKET_PREFIXES})
|
||||
* into memory, keyed by workspace-relative path (the key shape
|
||||
* {@code SkillFileService#applyBundleFiles} expects). Iteration order
|
||||
* follows {@link SkillBundleSource#assets()} enumeration order.
|
||||
|
||||
@ -13,6 +13,7 @@ import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.SkillSecurityService;
|
||||
import vip.mate.skill.runtime.SkillValidationResult;
|
||||
import vip.mate.skill.service.SkillFileService;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
|
||||
@ -39,6 +40,7 @@ import java.util.regex.Pattern;
|
||||
public class SkillManageTool {
|
||||
|
||||
private final SkillService skillService;
|
||||
private final SkillFileService skillFileService;
|
||||
private final SkillSecurityService securityService;
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final SkillRuntimeService runtimeService;
|
||||
@ -82,10 +84,10 @@ public class SkillManageTool {
|
||||
- create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body)
|
||||
- edit: Replace entire skill content (for major rewrites; preferred when changing version + body together)
|
||||
- patch: Find-and-replace a specific section (for small targeted fixes)
|
||||
- write_file: Write a supporting file under the skill's references/ or scripts/ directory
|
||||
(e.g. a long reference doc the SKILL.md links to, or a re-runnable script). Put the
|
||||
file body in 'content' and the path in 'filePath'. Keep SKILL.md itself lean and move
|
||||
bulky detail into references/.
|
||||
- write_file: Write a supporting file under the skill's references/, scripts/ or
|
||||
templates/ directory (e.g. a long reference doc the SKILL.md links to, a re-runnable
|
||||
script, or an output template). Put the file body in 'content' and the path in
|
||||
'filePath'. Keep SKILL.md itself lean and move bulky detail into references/.
|
||||
- delete: Remove a skill
|
||||
|
||||
SKILL.md format example:
|
||||
@ -130,7 +132,7 @@ public class SkillManageTool {
|
||||
String newText,
|
||||
|
||||
@JsonProperty
|
||||
@JsonPropertyDescription("For write_file action: relative path under references/ or scripts/ (e.g. 'references/api.md', 'scripts/run.sh'). No '..' allowed.")
|
||||
@JsonPropertyDescription("For write_file action: relative path under references/, scripts/ or templates/ (e.g. 'references/api.md', 'scripts/run.sh', 'templates/report.html'). No '..' allowed.")
|
||||
String filePath,
|
||||
|
||||
// RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden
|
||||
@ -359,7 +361,7 @@ public class SkillManageTool {
|
||||
*/
|
||||
private String doWriteFile(String name, String filePath, String content) {
|
||||
if (filePath == null || filePath.isBlank()) {
|
||||
return "Error: filePath is required for write_file (e.g. 'references/api.md' or 'scripts/run.sh').";
|
||||
return "Error: filePath is required for write_file (e.g. 'references/api.md', 'scripts/run.sh' or 'templates/report.html').";
|
||||
}
|
||||
if (content == null) {
|
||||
return "Error: content is required for write_file action.";
|
||||
@ -386,12 +388,24 @@ public class SkillManageTool {
|
||||
workspaceManager.writeWorkspaceFile(name, filePath, content);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "Error: " + e.getMessage()
|
||||
+ " (paths must start with references/ or scripts/, and may not contain '..').";
|
||||
+ " (paths must start with references/, scripts/ or templates/, and may not contain '..').";
|
||||
} catch (Exception e) {
|
||||
log.error("[SkillManage] Failed to write file '{}' for skill '{}': {}", filePath, name, e.getMessage(), e);
|
||||
return "Error writing skill file: " + e.getMessage();
|
||||
}
|
||||
|
||||
// Mirror into the canonical mate_skill_file store so the file
|
||||
// survives node changes and is visible to DB-reading consumers
|
||||
// (admin file editor, multi-instance workspace sync).
|
||||
try {
|
||||
skillFileService.upsertFile(existing.getId(), filePath.replace('\\', '/'), content);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillManage] Canonical store write failed for '{}' of skill '{}': {}",
|
||||
filePath, name, e.getMessage());
|
||||
}
|
||||
|
||||
rescanQuietly(existing);
|
||||
|
||||
log.info("[SkillManage] Agent wrote skill file: skill={}, path={}", name, filePath);
|
||||
return "File '" + filePath + "' written to skill '" + name + "' (security scan: PASSED).";
|
||||
}
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
package vip.mate.skill.controller;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.skill.mcp.McpSkillBridge;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.model.SkillFileEntity;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.service.SkillFileService;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.workspace.SkillFileSyncer;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Bundle-file admin endpoints: list / read / write / delete with the
|
||||
* path envelope and builtin/virtual guards.
|
||||
*/
|
||||
class SkillControllerBundleFilesTest {
|
||||
|
||||
private SkillService skillService;
|
||||
private SkillRuntimeService runtimeService;
|
||||
private SkillWorkspaceManager workspaceManager;
|
||||
private SkillFileSyncer fileSyncer;
|
||||
private SkillFileService fileService;
|
||||
private SkillController controller;
|
||||
|
||||
private static final long SID = 1_900_000_001_000_000_902L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
skillService = mock(SkillService.class);
|
||||
runtimeService = mock(SkillRuntimeService.class);
|
||||
workspaceManager = mock(SkillWorkspaceManager.class);
|
||||
fileSyncer = mock(SkillFileSyncer.class);
|
||||
fileService = mock(SkillFileService.class);
|
||||
controller = new SkillController(
|
||||
skillService, runtimeService, null, workspaceManager, null, fileSyncer,
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
fileService);
|
||||
}
|
||||
|
||||
private SkillEntity skill(boolean builtin) {
|
||||
SkillEntity s = new SkillEntity();
|
||||
s.setId(SID);
|
||||
s.setName("demo-skill");
|
||||
s.setBuiltin(builtin);
|
||||
return s;
|
||||
}
|
||||
|
||||
private SkillFileEntity row(String path, String content) {
|
||||
SkillFileEntity e = new SkillFileEntity();
|
||||
e.setId(1L);
|
||||
e.setSkillId(SID);
|
||||
e.setFilePath(path);
|
||||
e.setContent(content);
|
||||
e.setContentSize(content.getBytes().length);
|
||||
e.setSha256("h");
|
||||
return e;
|
||||
}
|
||||
|
||||
// ==================== path envelope ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("normalizeBundlePath accepts the three buckets and rejects escapes")
|
||||
void pathEnvelope() {
|
||||
assertThat(SkillController.normalizeBundlePath("scripts/run.py")).isEqualTo("scripts/run.py");
|
||||
assertThat(SkillController.normalizeBundlePath("references/a/b.md")).isEqualTo("references/a/b.md");
|
||||
assertThat(SkillController.normalizeBundlePath("templates/report.html")).isEqualTo("templates/report.html");
|
||||
assertThat(SkillController.normalizeBundlePath("templates\\r.html")).isEqualTo("templates/r.html");
|
||||
|
||||
assertThat(SkillController.normalizeBundlePath(null)).isNull();
|
||||
assertThat(SkillController.normalizeBundlePath(" ")).isNull();
|
||||
assertThat(SkillController.normalizeBundlePath("SKILL.md")).isNull();
|
||||
assertThat(SkillController.normalizeBundlePath("scripts/../etc/passwd")).isNull();
|
||||
assertThat(SkillController.normalizeBundlePath("/scripts/run.py")).isNull();
|
||||
assertThat(SkillController.normalizeBundlePath("scripts/")).isNull();
|
||||
assertThat(SkillController.normalizeBundlePath("scripts//x.py")).isNull();
|
||||
assertThat(SkillController.normalizeBundlePath("outputs/x.txt")).isNull();
|
||||
}
|
||||
|
||||
// ==================== list ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("list returns rows without content and self-heals an empty store")
|
||||
void listSelfHeals() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill(false));
|
||||
when(fileService.listBySkillId(SID))
|
||||
.thenReturn(List.of())
|
||||
.thenReturn(List.of(row("scripts/run.py", "print()")));
|
||||
|
||||
R<List<Map<String, Object>>> resp = controller.listBundleFiles(SID, null);
|
||||
|
||||
verify(fileSyncer).syncOne(any(SkillEntity.class));
|
||||
assertThat(resp.getData()).hasSize(1);
|
||||
assertThat(resp.getData().get(0)).containsEntry("path", "scripts/run.py");
|
||||
assertThat(resp.getData().get(0)).doesNotContainKey("content");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("list on a virtual MCP skill id returns empty without a DB lookup")
|
||||
void listVirtualIsEmpty() {
|
||||
R<List<Map<String, Object>>> resp =
|
||||
controller.listBundleFiles(McpSkillBridge.VIRTUAL_ID_BASE + 7L, null);
|
||||
assertThat(resp.getData()).isEmpty();
|
||||
verify(skillService, never()).getSkill(any());
|
||||
}
|
||||
|
||||
// ==================== write ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("put writes the canonical row, materializes the cache, and rescans")
|
||||
void putHappyPath() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill(false));
|
||||
when(fileService.upsertFile(SID, "templates/report.html", "<html/>"))
|
||||
.thenReturn(row("templates/report.html", "<html/>"));
|
||||
|
||||
R<Map<String, Object>> resp = controller.putBundleFileContent(SID,
|
||||
Map.of("path", "templates/report.html", "content", "<html/>"), null);
|
||||
|
||||
assertThat(resp.getData()).containsEntry("path", "templates/report.html");
|
||||
verify(fileService).upsertFile(SID, "templates/report.html", "<html/>");
|
||||
verify(workspaceManager).writeWorkspaceFile("demo-skill", "templates/report.html", "<html/>");
|
||||
verify(runtimeService).rescanSingle(any(SkillEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("put on a builtin skill is refused")
|
||||
void putBuiltinRefused() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill(true));
|
||||
|
||||
R<Map<String, Object>> resp = controller.putBundleFileContent(SID,
|
||||
Map.of("path", "scripts/x.py", "content", "x"), null);
|
||||
|
||||
assertThat(resp.getMsg()).contains("read-only");
|
||||
verify(fileService, never()).upsertFile(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("put with an out-of-bucket path is refused")
|
||||
void putBadPathRefused() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill(false));
|
||||
|
||||
R<Map<String, Object>> resp = controller.putBundleFileContent(SID,
|
||||
Map.of("path", "secrets/creds.txt", "content", "x"), null);
|
||||
|
||||
assertThat(resp.getMsg()).contains("Invalid file path");
|
||||
verify(fileService, never()).upsertFile(any(), any(), any());
|
||||
}
|
||||
|
||||
// ==================== delete ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("delete removes the row and the workspace cache file")
|
||||
void deleteHappyPath() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill(false));
|
||||
when(fileService.deleteFile(SID, "scripts/run.py")).thenReturn(true);
|
||||
|
||||
R<Map<String, Object>> resp = controller.deleteBundleFile(SID, "scripts/run.py", null);
|
||||
|
||||
assertThat(resp.getData()).containsEntry("removed", true);
|
||||
verify(workspaceManager).deleteWorkspaceFile("demo-skill", "scripts/run.py");
|
||||
verify(runtimeService).rescanSingle(any(SkillEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("delete on a builtin skill is refused")
|
||||
void deleteBuiltinRefused() {
|
||||
when(skillService.getSkill(SID)).thenReturn(skill(true));
|
||||
|
||||
R<Map<String, Object>> resp = controller.deleteBundleFile(SID, "scripts/run.py", null);
|
||||
|
||||
assertThat(resp.getMsg()).contains("read-only");
|
||||
verify(fileService, never()).deleteFile(any(), any());
|
||||
}
|
||||
}
|
||||
@ -58,7 +58,7 @@ class SkillControllerLifecycleTest {
|
||||
controller = new SkillController(
|
||||
skillService, null, null, null, null, null, null, null, null, null, null,
|
||||
agentBindingService, null, null,
|
||||
skillLifecycleService, skillCuratorJob, skillCuratorReportStore);
|
||||
skillLifecycleService, skillCuratorJob, skillCuratorReportStore, null);
|
||||
}
|
||||
|
||||
private SkillEntity skill(String state, boolean builtin) {
|
||||
|
||||
@ -55,7 +55,8 @@ class SkillControllerListEnabledTest {
|
||||
acpSkillBridge,
|
||||
/* skillLifecycleService */ null,
|
||||
/* skillCuratorJob */ null,
|
||||
/* skillCuratorReportStore */ null);
|
||||
/* skillCuratorReportStore */ null,
|
||||
/* skillFileService */ null);
|
||||
// listSkills() supplies realSkillNames() for shadow base — default
|
||||
// to empty so each test can override.
|
||||
when(skillService.listSkills(null)).thenReturn(List.of());
|
||||
|
||||
@ -26,7 +26,7 @@ class SkillControllerVirtualGuardTest {
|
||||
|
||||
private final SkillController controller = new SkillController(
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null);
|
||||
null, null, null, null, null);
|
||||
|
||||
@Test
|
||||
@DisplayName("update on a virtual MCP skill id is rejected before hitting the service")
|
||||
@ -65,7 +65,7 @@ class SkillControllerVirtualGuardTest {
|
||||
McpSkillBridge bridge = mock(McpSkillBridge.class);
|
||||
SkillController c = new SkillController(
|
||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
bridge, null, null, null, null);
|
||||
bridge, null, null, null, null, null);
|
||||
long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
|
||||
SkillEntity toggled = new SkillEntity();
|
||||
toggled.setName("github");
|
||||
@ -98,7 +98,7 @@ class SkillControllerVirtualGuardTest {
|
||||
SkillController real = new SkillController(
|
||||
mock(vip.mate.skill.service.SkillService.class),
|
||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null);
|
||||
null, null, null, null, null);
|
||||
long snowflakeId = 1_900_000_001_000_000_902L;
|
||||
// updateSkill on a mocked SkillService returns null without throwing,
|
||||
// which is fine — we just need to confirm the guard didn't fire.
|
||||
|
||||
@ -7,6 +7,7 @@ import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.SkillSecurityService;
|
||||
import vip.mate.skill.runtime.SkillValidationResult;
|
||||
import vip.mate.skill.service.SkillFileService;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
|
||||
@ -30,6 +31,7 @@ import static org.mockito.Mockito.when;
|
||||
class SkillManageToolWriteFileTest {
|
||||
|
||||
private SkillService skillService;
|
||||
private SkillFileService skillFileService;
|
||||
private SkillSecurityService securityService;
|
||||
private SkillWorkspaceManager workspaceManager;
|
||||
private SkillManageTool tool;
|
||||
@ -37,14 +39,16 @@ class SkillManageToolWriteFileTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
skillService = mock(SkillService.class);
|
||||
skillFileService = mock(SkillFileService.class);
|
||||
securityService = mock(SkillSecurityService.class);
|
||||
workspaceManager = mock(SkillWorkspaceManager.class);
|
||||
SkillRuntimeService runtimeService = mock(SkillRuntimeService.class);
|
||||
tool = new SkillManageTool(skillService, securityService, workspaceManager, runtimeService);
|
||||
tool = new SkillManageTool(skillService, skillFileService, securityService, workspaceManager, runtimeService);
|
||||
}
|
||||
|
||||
private SkillEntity skill(String name, boolean builtin) {
|
||||
SkillEntity s = new SkillEntity();
|
||||
s.setId(42L);
|
||||
s.setName(name);
|
||||
s.setBuiltin(builtin);
|
||||
s.setSkillContent("---\nname: " + name + "\n---\n# x");
|
||||
@ -69,6 +73,22 @@ class SkillManageToolWriteFileTest {
|
||||
|
||||
assertTrue(result.startsWith("File 'scripts/run.sh' written"), result);
|
||||
verify(workspaceManager, times(1)).writeWorkspaceFile("my-skill", "scripts/run.sh", "echo hi");
|
||||
// The canonical store row must be written too, not just the FS cache.
|
||||
verify(skillFileService, times(1)).upsertFile(42L, "scripts/run.sh", "echo hi");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("write_file accepts templates/ paths")
|
||||
void writesTemplateFile() {
|
||||
when(skillService.findByName("my-skill")).thenReturn(skill("my-skill", false));
|
||||
scanPasses();
|
||||
|
||||
String result = tool.skill_manage("write_file", "my-skill", "<html></html>",
|
||||
null, null, "templates/report.html", null);
|
||||
|
||||
assertTrue(result.startsWith("File 'templates/report.html' written"), result);
|
||||
verify(workspaceManager, times(1)).writeWorkspaceFile("my-skill", "templates/report.html", "<html></html>");
|
||||
verify(skillFileService, times(1)).upsertFile(42L, "templates/report.html", "<html></html>");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -262,6 +262,18 @@ export const skillApi = {
|
||||
refreshRuntime: () => http.post('/skills/runtime/refresh'),
|
||||
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`),
|
||||
getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`),
|
||||
/**
|
||||
* Bundle files (scripts/ + references/ + templates/) — canonical rows in
|
||||
* mate_skill_file; writes also materialize the workspace cache and
|
||||
* re-resolve the skill.
|
||||
*/
|
||||
listFiles: (id: string | number) => http.get(`/skills/${id}/files`),
|
||||
getFileContent: (id: string | number, path: string) =>
|
||||
http.get(`/skills/${id}/files/content`, { params: { path } }),
|
||||
saveFileContent: (id: string | number, path: string, content: string) =>
|
||||
http.put(`/skills/${id}/files/content`, { path, content }),
|
||||
deleteFile: (id: string | number, path: string) =>
|
||||
http.delete(`/skills/${id}/files`, { params: { path } }),
|
||||
// RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + reverse lookup
|
||||
requirements: (id: string | number) => http.get(`/skills/${id}/requirements`),
|
||||
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),
|
||||
|
||||
@ -3874,6 +3874,7 @@ export default {
|
||||
title: 'Skill detail',
|
||||
overview: 'Overview',
|
||||
body: 'Body',
|
||||
files: 'Files',
|
||||
manifest: 'Manifest',
|
||||
tools: 'Tools',
|
||||
features: 'Features',
|
||||
@ -3933,6 +3934,20 @@ export default {
|
||||
secretDeleteSuccess: 'Secret deleted',
|
||||
secretDeleteFailed: 'Failed to delete',
|
||||
secretLoadFailed: 'Failed to load secrets',
|
||||
filesTitle: 'Bundle files',
|
||||
filesHint: 'Files under scripts/, references/ and templates/. The database is the single source of truth; saves sync every node\'s workspace and take effect for agents immediately.',
|
||||
filesEmpty: 'This skill has no supporting files yet.',
|
||||
fileNew: 'New file',
|
||||
fileEdit: 'Edit',
|
||||
fileDelete: 'Delete',
|
||||
fileDeleteConfirm: 'Delete "{path}"? This removes both the database record and the workspace file. Cannot be undone.',
|
||||
fileSaved: 'File saved',
|
||||
fileDeleted: 'File deleted',
|
||||
fileLoadFailed: 'Failed to read file',
|
||||
fileSaveFailed: 'Save failed',
|
||||
filePathPlaceholder: 'Path, e.g. references/api.md, scripts/run.py, templates/report.html',
|
||||
fileContentPlaceholder: 'File content…',
|
||||
builtinFilesReadonly: 'Built-in skill files are restored from the shipped bundle on upgrade — read-only here.',
|
||||
},
|
||||
runtime: {
|
||||
disabled: 'Disabled',
|
||||
|
||||
@ -3966,6 +3966,7 @@ export default {
|
||||
title: '技能详情',
|
||||
overview: '概览',
|
||||
body: '正文',
|
||||
files: '文件',
|
||||
manifest: 'Manifest',
|
||||
tools: '工具',
|
||||
features: '特性',
|
||||
@ -4025,6 +4026,20 @@ export default {
|
||||
secretDeleteSuccess: '密钥已删除',
|
||||
secretDeleteFailed: '删除失败',
|
||||
secretLoadFailed: '加载密钥列表失败',
|
||||
filesTitle: '技能文件',
|
||||
filesHint: '技能包内的 scripts/、references/、templates/ 文件。数据库为唯一事实源,保存后立即同步各节点工作区并对 agent 生效。',
|
||||
filesEmpty: '该技能还没有任何附属文件。',
|
||||
fileNew: '新建文件',
|
||||
fileEdit: '编辑',
|
||||
fileDelete: '删除',
|
||||
fileDeleteConfirm: '删除文件 "{path}"?此操作同时移除数据库记录和工作区文件,不可撤销。',
|
||||
fileSaved: '文件已保存',
|
||||
fileDeleted: '文件已删除',
|
||||
fileLoadFailed: '读取文件失败',
|
||||
fileSaveFailed: '保存失败',
|
||||
filePathPlaceholder: '路径,如 references/api.md、scripts/run.py、templates/report.html',
|
||||
fileContentPlaceholder: '文件内容…',
|
||||
builtinFilesReadonly: '内置技能的文件随发行包升级恢复,此处只读。',
|
||||
},
|
||||
runtime: {
|
||||
disabled: '已停用',
|
||||
|
||||
@ -271,6 +271,10 @@
|
||||
<button class="detail-tab" :class="{ active: detailTab === 'body' }" @click="detailTab = 'body'">
|
||||
{{ t('skills.detail.body') }}
|
||||
</button>
|
||||
<button v-if="!isVirtualSkill" class="detail-tab" :class="{ active: detailTab === 'files' }" @click="detailTab = 'files'">
|
||||
{{ t('skills.detail.files') }}
|
||||
<span v-if="detailFiles.length > 0" class="tab-count">{{ detailFiles.length }}</span>
|
||||
</button>
|
||||
<button class="detail-tab" :class="{ active: detailTab === 'tools' }" @click="detailTab = 'tools'">
|
||||
{{ t('skills.detail.tools') }}
|
||||
<span v-if="detailToolsCount > 0" class="tab-count">{{ detailToolsCount }}</span>
|
||||
@ -468,6 +472,98 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Files tab — bundle files (scripts/ + references/ + templates/) -->
|
||||
<div v-if="detailTab === 'files'" class="detail-section">
|
||||
<div class="detail-block">
|
||||
<div class="detail-block-head">
|
||||
<h4 class="detail-block-title">{{ t('skills.detail.filesTitle') }}</h4>
|
||||
<button
|
||||
v-if="canEditFiles && !creatingFile"
|
||||
class="detail-edit-btn detail-edit-primary"
|
||||
@click="startCreateFile"
|
||||
>
|
||||
+ {{ t('skills.detail.fileNew') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="detail-hint">{{ t('skills.detail.filesHint') }}</p>
|
||||
<p v-if="isBuiltinDetail" class="detail-hint">{{ t('skills.detail.builtinFilesReadonly') }}</p>
|
||||
|
||||
<!-- New-file form -->
|
||||
<div v-if="creatingFile" class="skill-file-create">
|
||||
<input
|
||||
v-model.trim="newFileForm.path"
|
||||
type="text"
|
||||
class="skill-file-path-input"
|
||||
:placeholder="t('skills.detail.filePathPlaceholder')"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newFileForm.content"
|
||||
class="skill-file-editor"
|
||||
:placeholder="t('skills.detail.fileContentPlaceholder')"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<button class="detail-edit-btn detail-edit-cancel" @click="creatingFile = false" :disabled="savingFile">
|
||||
{{ t('skills.actions.cancel') }}
|
||||
</button>
|
||||
<button class="detail-edit-btn detail-edit-save" @click="saveNewFile" :disabled="savingFile || !newFileForm.path">
|
||||
{{ savingFile ? t('common.loading') : t('skills.actions.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="detailFilesLoading" class="detail-empty">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="detailFiles.length === 0 && !creatingFile" class="detail-empty">
|
||||
{{ t('skills.detail.filesEmpty') }}
|
||||
</p>
|
||||
<ul v-else class="skill-file-list">
|
||||
<li v-for="f in detailFiles" :key="f.path">
|
||||
<button
|
||||
class="skill-file-item"
|
||||
:class="{ active: activeFile?.path === f.path }"
|
||||
@click="openFile(f)"
|
||||
>
|
||||
<code class="skill-file-path">{{ f.path }}</code>
|
||||
<span class="skill-file-size">{{ formatFileSize(f.size) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Viewer / editor for the selected file -->
|
||||
<template v-if="activeFile && !creatingFile">
|
||||
<div class="detail-block-head detail-subhead">
|
||||
<h4 class="detail-block-title"><code>{{ activeFile.path }}</code></h4>
|
||||
<div class="edit-actions">
|
||||
<template v-if="!editingFile">
|
||||
<button v-if="canEditFiles" class="detail-edit-btn detail-edit-primary" @click="startEditFile">
|
||||
{{ t('skills.detail.fileEdit') }}
|
||||
</button>
|
||||
<button v-if="canEditFiles" class="detail-edit-btn detail-edit-cancel" @click="removeActiveFile" :disabled="savingFile">
|
||||
{{ t('skills.detail.fileDelete') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="detail-edit-btn detail-edit-cancel" @click="editingFile = false" :disabled="savingFile">
|
||||
{{ t('skills.actions.cancel') }}
|
||||
</button>
|
||||
<button class="detail-edit-btn detail-edit-save" @click="saveActiveFile" :disabled="savingFile">
|
||||
{{ savingFile ? t('common.loading') : t('skills.actions.save') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="fileContentLoading" class="detail-empty">{{ t('common.loading') }}</p>
|
||||
<pre v-else-if="!editingFile" class="detail-pre">{{ activeFileContent }}</pre>
|
||||
<textarea
|
||||
v-else
|
||||
v-model="fileEditContent"
|
||||
class="skill-file-editor"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tools tab -->
|
||||
<div v-if="detailTab === 'tools'" class="detail-section">
|
||||
<p v-if="detailToolsCount === 0" class="detail-empty">{{ t('skills.detail.noTools') }}</p>
|
||||
@ -763,7 +859,7 @@ const rescanning = ref<Record<string, boolean>>({})
|
||||
* tab for back-compat with any deep links that may pass it. */
|
||||
const detailDrawerVisible = ref(false)
|
||||
const detailSkill = ref<Skill | null>(null)
|
||||
const detailTab = ref<'overview' | 'body' | 'manifest' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory'>('overview')
|
||||
const detailTab = ref<'overview' | 'body' | 'files' | 'manifest' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory'>('overview')
|
||||
const detailLessonsRaw = ref<string>('')
|
||||
const detailLessonsLoading = ref(false)
|
||||
const detailEmployees = ref<Array<{ id: number; name: string; icon?: string; binding?: 'explicit' | 'implicit' }>>([])
|
||||
@ -811,6 +907,142 @@ const editBodyForm = ref<{ skillContent: string; sourceCode: string }>({
|
||||
sourceCode: '',
|
||||
})
|
||||
|
||||
// ==================== Bundle files tab ====================
|
||||
|
||||
interface SkillBundleFile {
|
||||
path: string
|
||||
size?: number
|
||||
sha256?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
const detailFiles = ref<SkillBundleFile[]>([])
|
||||
const detailFilesLoading = ref(false)
|
||||
const activeFile = ref<SkillBundleFile | null>(null)
|
||||
const activeFileContent = ref('')
|
||||
const fileContentLoading = ref(false)
|
||||
const editingFile = ref(false)
|
||||
const fileEditContent = ref('')
|
||||
const savingFile = ref(false)
|
||||
const creatingFile = ref(false)
|
||||
const newFileForm = ref<{ path: string; content: string }>({ path: '', content: '' })
|
||||
|
||||
/** Builtin bundles are restored from the shipped jar on upgrade, and
|
||||
* virtual skills own no files — both stay view-only. */
|
||||
const canEditFiles = computed(() => !isVirtualSkill.value && !isBuiltinDetail.value)
|
||||
|
||||
function resetFilesTab() {
|
||||
detailFiles.value = []
|
||||
activeFile.value = null
|
||||
activeFileContent.value = ''
|
||||
editingFile.value = false
|
||||
creatingFile.value = false
|
||||
newFileForm.value = { path: '', content: '' }
|
||||
}
|
||||
|
||||
async function loadDetailFiles() {
|
||||
if (!detailSkill.value || isVirtualSkill.value) return
|
||||
detailFilesLoading.value = true
|
||||
try {
|
||||
const res: any = await skillApi.listFiles(detailSkill.value.id)
|
||||
detailFiles.value = res?.data || []
|
||||
} catch {
|
||||
detailFiles.value = []
|
||||
} finally {
|
||||
detailFilesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openFile(file: SkillBundleFile) {
|
||||
if (!detailSkill.value) return
|
||||
activeFile.value = file
|
||||
editingFile.value = false
|
||||
fileContentLoading.value = true
|
||||
try {
|
||||
const res: any = await skillApi.getFileContent(detailSkill.value.id, file.path)
|
||||
activeFileContent.value = res?.data?.content ?? ''
|
||||
} catch (e: any) {
|
||||
activeFileContent.value = ''
|
||||
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileLoadFailed'))
|
||||
} finally {
|
||||
fileContentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditFile() {
|
||||
fileEditContent.value = activeFileContent.value
|
||||
editingFile.value = true
|
||||
}
|
||||
|
||||
async function saveActiveFile() {
|
||||
if (!detailSkill.value || !activeFile.value || savingFile.value) return
|
||||
savingFile.value = true
|
||||
try {
|
||||
await skillApi.saveFileContent(detailSkill.value.id, activeFile.value.path, fileEditContent.value)
|
||||
activeFileContent.value = fileEditContent.value
|
||||
editingFile.value = false
|
||||
mcToast.success(t('skills.detail.fileSaved'))
|
||||
loadDetailFiles()
|
||||
} catch (e: any) {
|
||||
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileSaveFailed'))
|
||||
} finally {
|
||||
savingFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeActiveFile() {
|
||||
if (!detailSkill.value || !activeFile.value || savingFile.value) return
|
||||
const ok = await mcConfirm({
|
||||
title: t('skills.detail.fileDelete'),
|
||||
message: t('skills.detail.fileDeleteConfirm', { path: activeFile.value.path }),
|
||||
tone: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
savingFile.value = true
|
||||
try {
|
||||
await skillApi.deleteFile(detailSkill.value.id, activeFile.value.path)
|
||||
activeFile.value = null
|
||||
activeFileContent.value = ''
|
||||
mcToast.success(t('skills.detail.fileDeleted'))
|
||||
loadDetailFiles()
|
||||
} catch (e: any) {
|
||||
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileSaveFailed'))
|
||||
} finally {
|
||||
savingFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startCreateFile() {
|
||||
creatingFile.value = true
|
||||
activeFile.value = null
|
||||
activeFileContent.value = ''
|
||||
editingFile.value = false
|
||||
newFileForm.value = { path: 'references/', content: '' }
|
||||
}
|
||||
|
||||
async function saveNewFile() {
|
||||
if (!detailSkill.value || savingFile.value || !newFileForm.value.path) return
|
||||
savingFile.value = true
|
||||
try {
|
||||
await skillApi.saveFileContent(detailSkill.value.id, newFileForm.value.path, newFileForm.value.content)
|
||||
creatingFile.value = false
|
||||
mcToast.success(t('skills.detail.fileSaved'))
|
||||
await loadDetailFiles()
|
||||
const created = detailFiles.value.find(f => f.path === newFileForm.value.path)
|
||||
if (created) openFile(created)
|
||||
} catch (e: any) {
|
||||
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileSaveFailed'))
|
||||
} finally {
|
||||
savingFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(size?: number): string {
|
||||
if (size == null) return ''
|
||||
if (size < 1024) return `${size} B`
|
||||
return `${(size / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
/** New-skill modal — pared down to the two questions that *must* be answered
|
||||
* at creation time. Everything else is filled in via the drawer. */
|
||||
const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' })
|
||||
@ -884,7 +1116,7 @@ const detailFeaturesCount = computed(() => detailFeatures.value.length)
|
||||
|
||||
function openDetailDrawer(
|
||||
skill: Skill,
|
||||
tab: 'overview' | 'body' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory' = 'overview',
|
||||
tab: 'overview' | 'body' | 'files' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory' = 'overview',
|
||||
opts: { editIdentity?: boolean; editBody?: boolean } = {},
|
||||
) {
|
||||
detailSkill.value = skill
|
||||
@ -893,6 +1125,7 @@ function openDetailDrawer(
|
||||
detailEmployees.value = []
|
||||
editingIdentity.value = false
|
||||
editingBody.value = false
|
||||
resetFilesTab()
|
||||
detailDrawerVisible.value = true
|
||||
// The list rows are a snapshot from page load; the skill may have been
|
||||
// modified since (e.g. by an agent in a chat session). Refetch the row
|
||||
@ -979,6 +1212,11 @@ watch(detailTab, (tab) => {
|
||||
if (tab === 'memory' && detailDrawerVisible.value && detailEmployees.value.length === 0 && !detailEmployeesLoading.value) {
|
||||
loadDetailEmployees()
|
||||
}
|
||||
// Refetch on every entry — files can change out-of-band (agent tools,
|
||||
// other admins), and the list is cheap (no content payload).
|
||||
if (tab === 'files' && detailDrawerVisible.value && !detailFilesLoading.value) {
|
||||
loadDetailFiles()
|
||||
}
|
||||
})
|
||||
|
||||
const categoryTabs = computed(() => [
|
||||
@ -2110,6 +2348,15 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
|
||||
.detail-empty { color: var(--mc-text-tertiary); font-size: 13px; font-style: italic; }
|
||||
.detail-pre { background: var(--mc-bg-sunken); padding: 12px; border-radius: 8px; max-height: 480px; overflow: auto; font-size: 12px; line-height: 1.5; color: var(--mc-text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; white-space: pre-wrap; word-break: break-word; }
|
||||
.detail-tool-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.skill-file-list { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.skill-file-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 6px 10px; background: var(--mc-bg-sunken); border: 1px solid transparent; border-radius: 8px; cursor: pointer; text-align: left; }
|
||||
.skill-file-item:hover { border-color: var(--mc-border-strong, var(--mc-border)); }
|
||||
.skill-file-item.active { border-color: var(--mc-accent, var(--mc-text-primary)); }
|
||||
.skill-file-path { font-size: 12px; color: var(--mc-text-primary); word-break: break-all; }
|
||||
.skill-file-size { flex-shrink: 0; font-size: 11px; color: var(--mc-text-tertiary); }
|
||||
.skill-file-editor { width: 100%; min-height: 220px; margin-top: 8px; padding: 12px; background: var(--mc-bg-sunken); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 12px; line-height: 1.5; color: var(--mc-text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; resize: vertical; }
|
||||
.skill-file-create { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.skill-file-path-input { width: 100%; padding: 8px 10px; background: var(--mc-bg-sunken); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; }
|
||||
.detail-tool-item code { display: block; padding: 6px 10px; background: var(--mc-bg-sunken); border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); }
|
||||
.detail-hint { margin-top: 12px; font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; }
|
||||
.detail-feature-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user