mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +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);
|
case STATUS -> buildStatusReply(channelEntity, conversationId);
|
||||||
};
|
};
|
||||||
if (replyTarget != null && reply != null) {
|
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={}",
|
log.info("[{}] Magic command handled: {} conversationId={}, sender={}",
|
||||||
adapter.getChannelType(), command.type(), conversationId,
|
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.SkillPackageResolver;
|
||||||
import vip.mate.skill.runtime.SkillCatalogSort;
|
import vip.mate.skill.runtime.SkillCatalogSort;
|
||||||
import vip.mate.skill.runtime.SkillCatalogSorter;
|
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.service.SkillService;
|
||||||
import vip.mate.skill.synthesis.SkillSynthesisService;
|
import vip.mate.skill.synthesis.SkillSynthesisService;
|
||||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
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.BundledSkillSyncer;
|
||||||
import vip.mate.skill.workspace.SkillFileSyncer;
|
import vip.mate.skill.workspace.SkillFileSyncer;
|
||||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||||
|
import vip.mate.skill.workspace.bundle.SkillBundleFiles;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.skill.lifecycle.ConfirmRequiredException;
|
import vip.mate.skill.lifecycle.ConfirmRequiredException;
|
||||||
import vip.mate.skill.lifecycle.LifecycleTransition;
|
import vip.mate.skill.lifecycle.LifecycleTransition;
|
||||||
@ -73,6 +76,7 @@ public class SkillController {
|
|||||||
private final SkillLifecycleService skillLifecycleService;
|
private final SkillLifecycleService skillLifecycleService;
|
||||||
private final SkillCuratorJob skillCuratorJob;
|
private final SkillCuratorJob skillCuratorJob;
|
||||||
private final SkillCuratorReportStore skillCuratorReportStore;
|
private final SkillCuratorReportStore skillCuratorReportStore;
|
||||||
|
private final SkillFileService skillFileService;
|
||||||
|
|
||||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -336,6 +340,157 @@ public class SkillController {
|
|||||||
return R.ok(body);
|
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
|
* Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge
|
||||||
* synthesizes those rows on the fly from the upstream connection
|
* 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;
|
Map<String, String> incoming = newFiles == null ? Map.of() : newFiles;
|
||||||
boolean newHasScripts = bucketHasEntries(incoming, "scripts/");
|
boolean newHasScripts = bucketHasEntries(incoming, "scripts/");
|
||||||
boolean newHasRefs = bucketHasEntries(incoming, "references/");
|
boolean newHasRefs = bucketHasEntries(incoming, "references/");
|
||||||
|
boolean newHasTemplates = bucketHasEntries(incoming, "templates/");
|
||||||
|
|
||||||
List<SkillFileEntity> existing = listBySkillId(skillId);
|
List<SkillFileEntity> existing = listBySkillId(skillId);
|
||||||
boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/"));
|
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 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 preserveScripts = !newHasScripts && existingHasScripts && !force;
|
||||||
boolean preserveRefs = !newHasRefs && existingHasRefs && !force;
|
boolean preserveRefs = !newHasRefs && existingHasRefs && !force;
|
||||||
|
boolean preserveTemplates = !newHasTemplates && existingHasTemplates && !force;
|
||||||
|
|
||||||
Map<String, SkillFileEntity> existingByPath = new HashMap<>();
|
Map<String, SkillFileEntity> existingByPath = new HashMap<>();
|
||||||
for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e);
|
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());
|
keepPaths.addAll(incoming.keySet());
|
||||||
|
|
||||||
int written = 0;
|
int written = 0;
|
||||||
@ -152,6 +162,9 @@ public class SkillFileService {
|
|||||||
if (preserveRefs) {
|
if (preserveRefs) {
|
||||||
log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
|
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);
|
return new ApplyResult(written, pruned, preserveScripts, preserveRefs);
|
||||||
}
|
}
|
||||||
@ -163,6 +176,61 @@ public class SkillFileService {
|
|||||||
return mapper.deleteBySkillId(skillId);
|
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) {
|
private boolean bucketHasEntries(Map<String, String> files, String prefix) {
|
||||||
for (String key : files.keySet()) {
|
for (String key : files.keySet()) {
|
||||||
if (key != null && key.startsWith(prefix)) return true;
|
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
|
* Mirrors canonical {@code mate_skill_file} rows down to each node's local
|
||||||
* workspace cache so {@code scripts/} and {@code references/} files exist
|
* workspace cache so {@code scripts/}, {@code references/} and
|
||||||
* on disk wherever the skill might run.
|
* {@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
|
* <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
|
* 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) {
|
private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) {
|
||||||
String relative = row.getFilePath();
|
String relative = row.getFilePath();
|
||||||
if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED;
|
if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED;
|
||||||
if (!relative.startsWith("references/") && !relative.startsWith("scripts/")) {
|
if (!SkillBundleFiles.isDbEligible(relative)) {
|
||||||
log.warn("Skipping skill_file row {} — path outside scripts/ or references/: {}",
|
log.warn("Skipping skill_file row {} — path outside the DB-persisted buckets ({}): {}",
|
||||||
row.getId(), relative);
|
row.getId(), SkillBundleFiles.DB_BUCKET_PREFIXES, relative);
|
||||||
return MaterializeOutcome.SKIPPED;
|
return MaterializeOutcome.SKIPPED;
|
||||||
}
|
}
|
||||||
if (relative.contains("..")) {
|
if (relative.contains("..")) {
|
||||||
@ -208,11 +208,11 @@ public class SkillFileSyncer {
|
|||||||
private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) {
|
private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) {
|
||||||
if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0;
|
if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0;
|
||||||
|
|
||||||
List<Path> roots = new ArrayList<>(2);
|
List<Path> roots = new ArrayList<>(SkillBundleFiles.DB_BUCKET_PREFIXES.size());
|
||||||
Path scripts = workspaceDir.resolve("scripts");
|
for (String prefix : SkillBundleFiles.DB_BUCKET_PREFIXES) {
|
||||||
Path references = workspaceDir.resolve("references");
|
Path root = workspaceDir.resolve(prefix.substring(0, prefix.length() - 1));
|
||||||
if (Files.isDirectory(scripts)) roots.add(scripts);
|
if (Files.isDirectory(root)) roots.add(root);
|
||||||
if (Files.isDirectory(references)) roots.add(references);
|
}
|
||||||
if (roots.isEmpty()) return 0;
|
if (roots.isEmpty()) return 0;
|
||||||
|
|
||||||
Map<String, String> ingested = new LinkedHashMap<>();
|
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/ 目录内容(保留目录本身)
|
* 清空 skill 工作区中的 references/ 和 scripts/ 目录内容(保留目录本身)
|
||||||
* 用于 overwrite 安装前清除旧版本残留文件
|
* 用于 overwrite 安装前清除旧版本残留文件
|
||||||
@ -531,8 +565,9 @@ public class SkillWorkspaceManager {
|
|||||||
// 归一化分隔符
|
// 归一化分隔符
|
||||||
String normalized = relativePath.replace("\\", "/");
|
String normalized = relativePath.replace("\\", "/");
|
||||||
|
|
||||||
// 必须以 references/ 或 scripts/ 开头
|
// 必须以 references/、scripts/ 或 templates/ 开头
|
||||||
if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) {
|
if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")
|
||||||
|
&& !normalized.startsWith("templates/")) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,16 +9,17 @@ import java.util.Map;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Helpers for the bundle file buckets that mirror into the canonical
|
* 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
|
* <p>Shared by the bundled-skill startup sync, the skill file syncer's
|
||||||
* classpath backfill so both agree on which paths are DB-persisted and how
|
* classpath backfill, and the admin file editor so all agree on which
|
||||||
* bundle contents are read into memory.
|
* paths are DB-persisted and how bundle contents are read into memory.
|
||||||
*/
|
*/
|
||||||
public final class SkillBundleFiles {
|
public final class SkillBundleFiles {
|
||||||
|
|
||||||
/** Path prefixes of the buckets persisted to {@code mate_skill_file}. */
|
/** 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() {
|
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
|
* into memory, keyed by workspace-relative path (the key shape
|
||||||
* {@code SkillFileService#applyBundleFiles} expects). Iteration order
|
* {@code SkillFileService#applyBundleFiles} expects). Iteration order
|
||||||
* follows {@link SkillBundleSource#assets()} enumeration 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.SkillRuntimeService;
|
||||||
import vip.mate.skill.runtime.SkillSecurityService;
|
import vip.mate.skill.runtime.SkillSecurityService;
|
||||||
import vip.mate.skill.runtime.SkillValidationResult;
|
import vip.mate.skill.runtime.SkillValidationResult;
|
||||||
|
import vip.mate.skill.service.SkillFileService;
|
||||||
import vip.mate.skill.service.SkillService;
|
import vip.mate.skill.service.SkillService;
|
||||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ import java.util.regex.Pattern;
|
|||||||
public class SkillManageTool {
|
public class SkillManageTool {
|
||||||
|
|
||||||
private final SkillService skillService;
|
private final SkillService skillService;
|
||||||
|
private final SkillFileService skillFileService;
|
||||||
private final SkillSecurityService securityService;
|
private final SkillSecurityService securityService;
|
||||||
private final SkillWorkspaceManager workspaceManager;
|
private final SkillWorkspaceManager workspaceManager;
|
||||||
private final SkillRuntimeService runtimeService;
|
private final SkillRuntimeService runtimeService;
|
||||||
@ -82,10 +84,10 @@ public class SkillManageTool {
|
|||||||
- create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body)
|
- 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)
|
- 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)
|
- 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
|
- write_file: Write a supporting file under the skill's references/, scripts/ or
|
||||||
(e.g. a long reference doc the SKILL.md links to, or a re-runnable script). Put the
|
templates/ directory (e.g. a long reference doc the SKILL.md links to, a re-runnable
|
||||||
file body in 'content' and the path in 'filePath'. Keep SKILL.md itself lean and move
|
script, or an output template). Put the file body in 'content' and the path in
|
||||||
bulky detail into references/.
|
'filePath'. Keep SKILL.md itself lean and move bulky detail into references/.
|
||||||
- delete: Remove a skill
|
- delete: Remove a skill
|
||||||
|
|
||||||
SKILL.md format example:
|
SKILL.md format example:
|
||||||
@ -130,7 +132,7 @@ public class SkillManageTool {
|
|||||||
String newText,
|
String newText,
|
||||||
|
|
||||||
@JsonProperty
|
@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,
|
String filePath,
|
||||||
|
|
||||||
// RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden
|
// 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) {
|
private String doWriteFile(String name, String filePath, String content) {
|
||||||
if (filePath == null || filePath.isBlank()) {
|
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) {
|
if (content == null) {
|
||||||
return "Error: content is required for write_file action.";
|
return "Error: content is required for write_file action.";
|
||||||
@ -386,12 +388,24 @@ public class SkillManageTool {
|
|||||||
workspaceManager.writeWorkspaceFile(name, filePath, content);
|
workspaceManager.writeWorkspaceFile(name, filePath, content);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return "Error: " + e.getMessage()
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("[SkillManage] Failed to write file '{}' for skill '{}': {}", filePath, name, e.getMessage(), e);
|
log.error("[SkillManage] Failed to write file '{}' for skill '{}': {}", filePath, name, e.getMessage(), e);
|
||||||
return "Error writing skill file: " + e.getMessage();
|
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);
|
log.info("[SkillManage] Agent wrote skill file: skill={}, path={}", name, filePath);
|
||||||
return "File '" + filePath + "' written to skill '" + name + "' (security scan: PASSED).";
|
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(
|
controller = new SkillController(
|
||||||
skillService, null, null, null, null, null, null, null, null, null, null,
|
skillService, null, null, null, null, null, null, null, null, null, null,
|
||||||
agentBindingService, null, null,
|
agentBindingService, null, null,
|
||||||
skillLifecycleService, skillCuratorJob, skillCuratorReportStore);
|
skillLifecycleService, skillCuratorJob, skillCuratorReportStore, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SkillEntity skill(String state, boolean builtin) {
|
private SkillEntity skill(String state, boolean builtin) {
|
||||||
|
|||||||
@ -55,7 +55,8 @@ class SkillControllerListEnabledTest {
|
|||||||
acpSkillBridge,
|
acpSkillBridge,
|
||||||
/* skillLifecycleService */ null,
|
/* skillLifecycleService */ null,
|
||||||
/* skillCuratorJob */ null,
|
/* skillCuratorJob */ null,
|
||||||
/* skillCuratorReportStore */ null);
|
/* skillCuratorReportStore */ null,
|
||||||
|
/* skillFileService */ null);
|
||||||
// listSkills() supplies realSkillNames() for shadow base — default
|
// listSkills() supplies realSkillNames() for shadow base — default
|
||||||
// to empty so each test can override.
|
// to empty so each test can override.
|
||||||
when(skillService.listSkills(null)).thenReturn(List.of());
|
when(skillService.listSkills(null)).thenReturn(List.of());
|
||||||
|
|||||||
@ -26,7 +26,7 @@ class SkillControllerVirtualGuardTest {
|
|||||||
|
|
||||||
private final SkillController controller = new SkillController(
|
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, null, null, null, null,
|
||||||
null, null, null, null);
|
null, null, null, null, null);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("update on a virtual MCP skill id is rejected before hitting the service")
|
@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);
|
McpSkillBridge bridge = mock(McpSkillBridge.class);
|
||||||
SkillController c = new SkillController(
|
SkillController c = new SkillController(
|
||||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
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;
|
long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
|
||||||
SkillEntity toggled = new SkillEntity();
|
SkillEntity toggled = new SkillEntity();
|
||||||
toggled.setName("github");
|
toggled.setName("github");
|
||||||
@ -98,7 +98,7 @@ class SkillControllerVirtualGuardTest {
|
|||||||
SkillController real = new SkillController(
|
SkillController real = new SkillController(
|
||||||
mock(vip.mate.skill.service.SkillService.class),
|
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, null, null, null,
|
||||||
null, null, null, null);
|
null, null, null, null, null);
|
||||||
long snowflakeId = 1_900_000_001_000_000_902L;
|
long snowflakeId = 1_900_000_001_000_000_902L;
|
||||||
// updateSkill on a mocked SkillService returns null without throwing,
|
// updateSkill on a mocked SkillService returns null without throwing,
|
||||||
// which is fine — we just need to confirm the guard didn't fire.
|
// 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.SkillRuntimeService;
|
||||||
import vip.mate.skill.runtime.SkillSecurityService;
|
import vip.mate.skill.runtime.SkillSecurityService;
|
||||||
import vip.mate.skill.runtime.SkillValidationResult;
|
import vip.mate.skill.runtime.SkillValidationResult;
|
||||||
|
import vip.mate.skill.service.SkillFileService;
|
||||||
import vip.mate.skill.service.SkillService;
|
import vip.mate.skill.service.SkillService;
|
||||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ import static org.mockito.Mockito.when;
|
|||||||
class SkillManageToolWriteFileTest {
|
class SkillManageToolWriteFileTest {
|
||||||
|
|
||||||
private SkillService skillService;
|
private SkillService skillService;
|
||||||
|
private SkillFileService skillFileService;
|
||||||
private SkillSecurityService securityService;
|
private SkillSecurityService securityService;
|
||||||
private SkillWorkspaceManager workspaceManager;
|
private SkillWorkspaceManager workspaceManager;
|
||||||
private SkillManageTool tool;
|
private SkillManageTool tool;
|
||||||
@ -37,14 +39,16 @@ class SkillManageToolWriteFileTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
skillService = mock(SkillService.class);
|
skillService = mock(SkillService.class);
|
||||||
|
skillFileService = mock(SkillFileService.class);
|
||||||
securityService = mock(SkillSecurityService.class);
|
securityService = mock(SkillSecurityService.class);
|
||||||
workspaceManager = mock(SkillWorkspaceManager.class);
|
workspaceManager = mock(SkillWorkspaceManager.class);
|
||||||
SkillRuntimeService runtimeService = mock(SkillRuntimeService.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) {
|
private SkillEntity skill(String name, boolean builtin) {
|
||||||
SkillEntity s = new SkillEntity();
|
SkillEntity s = new SkillEntity();
|
||||||
|
s.setId(42L);
|
||||||
s.setName(name);
|
s.setName(name);
|
||||||
s.setBuiltin(builtin);
|
s.setBuiltin(builtin);
|
||||||
s.setSkillContent("---\nname: " + name + "\n---\n# x");
|
s.setSkillContent("---\nname: " + name + "\n---\n# x");
|
||||||
@ -69,6 +73,22 @@ class SkillManageToolWriteFileTest {
|
|||||||
|
|
||||||
assertTrue(result.startsWith("File 'scripts/run.sh' written"), result);
|
assertTrue(result.startsWith("File 'scripts/run.sh' written"), result);
|
||||||
verify(workspaceManager, times(1)).writeWorkspaceFile("my-skill", "scripts/run.sh", "echo hi");
|
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
|
@Test
|
||||||
|
|||||||
@ -262,6 +262,18 @@ export const skillApi = {
|
|||||||
refreshRuntime: () => http.post('/skills/runtime/refresh'),
|
refreshRuntime: () => http.post('/skills/runtime/refresh'),
|
||||||
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`),
|
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`),
|
||||||
getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/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
|
// RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + reverse lookup
|
||||||
requirements: (id: string | number) => http.get(`/skills/${id}/requirements`),
|
requirements: (id: string | number) => http.get(`/skills/${id}/requirements`),
|
||||||
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),
|
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),
|
||||||
|
|||||||
@ -3874,6 +3874,7 @@ export default {
|
|||||||
title: 'Skill detail',
|
title: 'Skill detail',
|
||||||
overview: 'Overview',
|
overview: 'Overview',
|
||||||
body: 'Body',
|
body: 'Body',
|
||||||
|
files: 'Files',
|
||||||
manifest: 'Manifest',
|
manifest: 'Manifest',
|
||||||
tools: 'Tools',
|
tools: 'Tools',
|
||||||
features: 'Features',
|
features: 'Features',
|
||||||
@ -3933,6 +3934,20 @@ export default {
|
|||||||
secretDeleteSuccess: 'Secret deleted',
|
secretDeleteSuccess: 'Secret deleted',
|
||||||
secretDeleteFailed: 'Failed to delete',
|
secretDeleteFailed: 'Failed to delete',
|
||||||
secretLoadFailed: 'Failed to load secrets',
|
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: {
|
runtime: {
|
||||||
disabled: 'Disabled',
|
disabled: 'Disabled',
|
||||||
|
|||||||
@ -3966,6 +3966,7 @@ export default {
|
|||||||
title: '技能详情',
|
title: '技能详情',
|
||||||
overview: '概览',
|
overview: '概览',
|
||||||
body: '正文',
|
body: '正文',
|
||||||
|
files: '文件',
|
||||||
manifest: 'Manifest',
|
manifest: 'Manifest',
|
||||||
tools: '工具',
|
tools: '工具',
|
||||||
features: '特性',
|
features: '特性',
|
||||||
@ -4025,6 +4026,20 @@ export default {
|
|||||||
secretDeleteSuccess: '密钥已删除',
|
secretDeleteSuccess: '密钥已删除',
|
||||||
secretDeleteFailed: '删除失败',
|
secretDeleteFailed: '删除失败',
|
||||||
secretLoadFailed: '加载密钥列表失败',
|
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: {
|
runtime: {
|
||||||
disabled: '已停用',
|
disabled: '已停用',
|
||||||
|
|||||||
@ -271,6 +271,10 @@
|
|||||||
<button class="detail-tab" :class="{ active: detailTab === 'body' }" @click="detailTab = 'body'">
|
<button class="detail-tab" :class="{ active: detailTab === 'body' }" @click="detailTab = 'body'">
|
||||||
{{ t('skills.detail.body') }}
|
{{ t('skills.detail.body') }}
|
||||||
</button>
|
</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'">
|
<button class="detail-tab" :class="{ active: detailTab === 'tools' }" @click="detailTab = 'tools'">
|
||||||
{{ t('skills.detail.tools') }}
|
{{ t('skills.detail.tools') }}
|
||||||
<span v-if="detailToolsCount > 0" class="tab-count">{{ detailToolsCount }}</span>
|
<span v-if="detailToolsCount > 0" class="tab-count">{{ detailToolsCount }}</span>
|
||||||
@ -468,6 +472,98 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Tools tab -->
|
||||||
<div v-if="detailTab === 'tools'" class="detail-section">
|
<div v-if="detailTab === 'tools'" class="detail-section">
|
||||||
<p v-if="detailToolsCount === 0" class="detail-empty">{{ t('skills.detail.noTools') }}</p>
|
<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. */
|
* tab for back-compat with any deep links that may pass it. */
|
||||||
const detailDrawerVisible = ref(false)
|
const detailDrawerVisible = ref(false)
|
||||||
const detailSkill = ref<Skill | null>(null)
|
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 detailLessonsRaw = ref<string>('')
|
||||||
const detailLessonsLoading = ref(false)
|
const detailLessonsLoading = ref(false)
|
||||||
const detailEmployees = ref<Array<{ id: number; name: string; icon?: string; binding?: 'explicit' | 'implicit' }>>([])
|
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: '',
|
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
|
/** New-skill modal — pared down to the two questions that *must* be answered
|
||||||
* at creation time. Everything else is filled in via the drawer. */
|
* at creation time. Everything else is filled in via the drawer. */
|
||||||
const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' })
|
const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' })
|
||||||
@ -884,7 +1116,7 @@ const detailFeaturesCount = computed(() => detailFeatures.value.length)
|
|||||||
|
|
||||||
function openDetailDrawer(
|
function openDetailDrawer(
|
||||||
skill: Skill,
|
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 } = {},
|
opts: { editIdentity?: boolean; editBody?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
detailSkill.value = skill
|
detailSkill.value = skill
|
||||||
@ -893,6 +1125,7 @@ function openDetailDrawer(
|
|||||||
detailEmployees.value = []
|
detailEmployees.value = []
|
||||||
editingIdentity.value = false
|
editingIdentity.value = false
|
||||||
editingBody.value = false
|
editingBody.value = false
|
||||||
|
resetFilesTab()
|
||||||
detailDrawerVisible.value = true
|
detailDrawerVisible.value = true
|
||||||
// The list rows are a snapshot from page load; the skill may have been
|
// 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
|
// 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) {
|
if (tab === 'memory' && detailDrawerVisible.value && detailEmployees.value.length === 0 && !detailEmployeesLoading.value) {
|
||||||
loadDetailEmployees()
|
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(() => [
|
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-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-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; }
|
.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-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-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; }
|
.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