mateclaw/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
matevip 4ac72a90a0 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.
2026-07-22 15:39:59 +08:00

1110 lines
52 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.skill.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.service.AgentBindingService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import vip.mate.agent.model.AgentEntity;
import vip.mate.skill.lessons.SkillLessonsService;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.model.SkillEntity;
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;
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;
import vip.mate.skill.lifecycle.SkillCuratorJob;
import vip.mate.skill.lifecycle.SkillCuratorReport;
import vip.mate.skill.lifecycle.SkillCuratorReportStore;
import vip.mate.skill.lifecycle.SkillLifecycleService;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 技能管理接口
* <p>
* 提供技能的 CRUD、启用/禁用、按类型查询、技能摘要等能力。
* 对应前端 SkillMarket 页面。
*
* @author MateClaw Team
*/
@Tag(name = "技能管理")
@RestController
@RequestMapping("/api/v1/skills")
@RequiredArgsConstructor
public class SkillController {
private final SkillService skillService;
private final SkillRuntimeService skillRuntimeService;
private final SkillPackageResolver skillPackageResolver;
private final SkillWorkspaceManager workspaceManager;
private final BundledSkillSyncer bundledSkillSyncer;
private final SkillFileSyncer skillFileSyncer;
private final SkillSynthesisService synthesisService;
private final SkillDependencyChecker dependencyChecker;
private final SkillLessonsService lessonsService;
private final AgentSkillBindingMapper agentSkillBindingMapper;
private final AgentService agentService;
private final AgentBindingService agentBindingService;
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge;
private final SkillLifecycleService skillLifecycleService;
private final SkillCuratorJob skillCuratorJob;
private final SkillCuratorReportStore skillCuratorReportStore;
private final SkillFileService skillFileService;
@Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping
@RequireWorkspaceRole("member")
public R<IPage<SkillEntity>> list(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String skillType,
@RequestParam(required = false) Boolean enabled,
@RequestParam(required = false) String scanStatus,
@RequestParam(required = false) String sort,
@RequestParam(required = false) String source,
@RequestParam(required = false) String runtime,
@RequestParam(required = false) String lifecycleState,
@RequestParam(required = false) Long agentId) {
Set<Long> pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of();
if (pinnedSkillIds == null) pinnedSkillIds = Set.of();
IPage<SkillEntity> dbPage = skillService.pageSkills(
page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime,
pinnedSkillIds, workspaceId, lifecycleState);
// Virtual MCP/ACP skills mirror live servers and carry no lifecycle
// state — exclude them whenever the caller filters by lifecycleState
// (stale / archived / active), otherwise they leak into every tab.
List<SkillEntity> virtualSkills = (lifecycleState != null && !lifecycleState.isBlank())
? List.of()
: visibleVirtualSkills(
workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime);
if (!virtualSkills.isEmpty()) {
VirtualPageMergeResult merged = mergeVirtualTailPageRecords(
dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size);
dbPage.setRecords(merged.records());
dbPage.setTotal(merged.total());
}
return R.ok(dbPage);
}
@Operation(summary = "获取各类型技能计数tab 徽章用)")
@GetMapping("/counts")
@RequireWorkspaceRole("member")
public R<Map<String, Long>> counts(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
Map<String, Long> result = skillService.countByType(workspaceId);
Set<String> realNames = realSkillNames(workspaceId);
// RFC-090 §3.2 — virtual MCP-derived skills aren't in mate_skill,
// so countByType() misses them. Fold in the live count so the
// "MCP" and "all" tab badges match what the list endpoint shows.
try {
long virtualMcp = countUnshadowedVirtualSkills(
mcpSkillBridge.listMcpDerivedSkillEntities(), realNames);
if (virtualMcp > 0) {
result.merge("mcp", virtualMcp, Long::sum);
result.merge("all", virtualMcp, Long::sum);
}
} catch (Exception ignored) {
// Bridge failure must not break the badge fetch.
}
try {
long virtualAcp = countUnshadowedVirtualSkills(
acpSkillBridge.listAcpDerivedSkillEntities(), realNames);
if (virtualAcp > 0) {
result.merge("acp", virtualAcp, Long::sum);
result.merge("all", virtualAcp, Long::sum);
}
} catch (Exception ignored) {
// Same defensive stance as the MCP bridge above.
}
return R.ok(result);
}
/**
* Real skill rows own their slug. Same-name MCP/ACP virtual rows are
* shadowed even when the real row is disabled, matching the runtime status
* view and avoiding duplicate cards for the same capability. The comparison
* is name-only because {@code mate_skill.name} is the unique skill slug.
*/
static List<SkillEntity> filterShadowedVirtualSkills(List<SkillEntity> virtualSkills,
Set<String> realSkillNames) {
if (virtualSkills == null || virtualSkills.isEmpty()) return List.of();
Set<String> realNames = realSkillNames == null ? Set.of() : realSkillNames;
return virtualSkills.stream()
.filter(s -> s != null && !realNames.contains(s.getName()))
.toList();
}
static long countUnshadowedVirtualSkills(List<SkillEntity> virtualSkills,
Set<String> realSkillNames) {
return filterShadowedVirtualSkills(virtualSkills, realSkillNames).size();
}
/** Keep only enabled rows — gates virtual skills into enabled-only endpoints. */
static List<SkillEntity> enabledOnly(List<SkillEntity> skills) {
if (skills == null || skills.isEmpty()) return List.of();
return skills.stream()
.filter(s -> s != null && Boolean.TRUE.equals(s.getEnabled()))
.toList();
}
/**
* Keep MyBatis-Plus as the source of truth for DB pagination and append
* live virtual ACP/MCP rows after the DB rows. This produces one stable
* combined sequence:
* <pre>
* [all DB rows sorted by SQL] + [unshadowed live virtual rows]
* </pre>
*
* The tail ordering avoids shifting every DB page whenever a runtime ACP
* endpoint appears, and it keeps {@code total} consistent across all pages.
*/
static VirtualPageMergeResult mergeVirtualTailPageRecords(List<SkillEntity> dbRecords,
List<SkillEntity> virtualSkills,
long dbTotal,
int page,
int size) {
List<SkillEntity> records = new ArrayList<>(dbRecords == null ? List.of() : dbRecords);
List<SkillEntity> virtualRows = virtualSkills == null ? List.of() : virtualSkills;
long normalizedDbTotal = Math.max(dbTotal, 0);
long total = normalizedDbTotal + virtualRows.size();
if (virtualRows.isEmpty()) {
return new VirtualPageMergeResult(records, total);
}
int safePage = Math.max(page, 1);
int safeSize = Math.max(size, 1);
long startInclusive = (long) (safePage - 1) * safeSize;
long endExclusive = startInclusive + safeSize;
if (endExclusive <= normalizedDbTotal || records.size() >= safeSize) {
return new VirtualPageMergeResult(records, total);
}
long virtualStart = Math.max(0, startInclusive - normalizedDbTotal);
int remainingSlots = safeSize - records.size();
for (long i = virtualStart; i < virtualRows.size() && remainingSlots > 0; i++) {
records.add(virtualRows.get((int) i));
remainingSlots--;
}
return new VirtualPageMergeResult(records, total);
}
record VirtualPageMergeResult(List<SkillEntity> records, long total) {}
private List<SkillEntity> visibleVirtualSkills(Long workspaceId,
String keyword,
String skillType,
Boolean enabled,
String scanStatus,
String sort,
String source,
String runtime) {
String effectiveSource = source != null && !source.isBlank() ? source : skillType;
boolean includeMcpVirtuals = isAllSkillType(effectiveSource) || "mcp".equalsIgnoreCase(effectiveSource);
boolean includeAcpVirtuals = isAllSkillType(effectiveSource) || "acp".equalsIgnoreCase(effectiveSource);
if (!includeMcpVirtuals && !includeAcpVirtuals) return List.of();
Set<String> realNames = realSkillNames(workspaceId);
List<SkillEntity> result = new ArrayList<>();
if (includeMcpVirtuals) {
try {
result.addAll(filterVirtualSkills(mcpSkillBridge.listMcpDerivedSkillEntities(),
realNames, keyword, enabled, scanStatus, runtime));
} catch (Exception ignored) {
// Bridge failure must not break the Skills page.
}
}
if (includeAcpVirtuals) {
try {
result.addAll(filterVirtualSkills(acpSkillBridge.listAcpDerivedSkillEntities(),
realNames, keyword, enabled, scanStatus, runtime));
} catch (Exception ignored) {
// Bridge failure must not break the Skills page.
}
}
return SkillCatalogSorter.sortEntities(result, SkillCatalogSort.parse(sort));
}
private static List<SkillEntity> filterVirtualSkills(List<SkillEntity> virtualSkills,
Set<String> realNames,
String keyword,
Boolean enabled,
String scanStatus,
String runtime) {
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
String normalizedScan = scanStatus == null ? "" : scanStatus.trim().toUpperCase();
return filterShadowedVirtualSkills(virtualSkills, realNames).stream()
.filter(s -> kw.isEmpty() || containsIgnoreCase(s.getName(), kw)
|| containsIgnoreCase(s.getDescription(), kw)
|| containsIgnoreCase(s.getTags(), kw))
.filter(s -> enabled == null || enabled.equals(s.getEnabled()))
.filter(s -> normalizedScan.isEmpty()
|| normalizedScan.equalsIgnoreCase(s.getSecurityScanStatus()))
.filter(s -> SkillCatalogSorter.runtimeMatches(s, runtime))
.toList();
}
private static boolean isAllSkillType(String skillType) {
return skillType == null || skillType.isBlank() || "all".equalsIgnoreCase(skillType);
}
private static boolean containsIgnoreCase(String value, String lowerCaseNeedle) {
return value != null && value.toLowerCase().contains(lowerCaseNeedle);
}
/**
* Names of every real {@code mate_skill} row visible in {@code
* workspaceId} (builtin + workspace-owned). Used to shadow same-named
* MCP/ACP virtual skills so the catalog never shows two cards for one
* capability.
*/
private Set<String> realSkillNames(Long workspaceId) {
return skillService.listSkills(workspaceId).stream()
.map(SkillEntity::getName)
.collect(java.util.stream.Collectors.toSet());
}
@Operation(summary = "重新扫描单个技能RFC-042 §2.3.4")
@PostMapping("/{id}/rescan")
@RequireWorkspaceRole("admin")
public R<SkillEntity> rescan(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
return R.ok(skillService.rescanSecurity(id));
}
@Operation(summary = "Re-sync this skill's bundle files from DB → local workspace cache",
description = "Use after an out-of-band scripts/ change or to recover a missing local cache " +
"in a multi-instance deployment. Pulls every mate_skill_file row owned by the skill " +
"down to disk; if no rows exist yet but local files do, ingests them into the canonical store.")
@PostMapping("/{id}/sync-files")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> syncFiles(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
var report = skillFileSyncer.syncOne(skill);
Map<String, Object> body = new LinkedHashMap<>();
body.put("skillId", id);
body.put("name", skill.getName());
body.put("filesMaterialized", report.filesMaterialized());
body.put("filesAlreadyCurrent", report.filesAlreadyCurrent());
body.put("filesBackfilledFromDisk", report.filesBackfilledFromDisk());
body.put("backfilledFromDisk", report.didBackfillFromDisk());
return R.ok(body);
}
@Operation(summary = "Re-sync every skill's bundle files (admin)",
description = "Bulk variant of /sync-files; primarily for ops debugging when you suspect " +
"the local workspace is out of sync with the canonical store.")
@PostMapping("/sync-files")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> syncAllFiles() {
var report = skillFileSyncer.syncAll();
Map<String, Object> body = new LinkedHashMap<>();
body.put("skillsConsidered", report.skillsConsidered());
body.put("skillsBackfilled", report.skillsBackfilled());
body.put("filesMaterialized", report.filesMaterialized());
body.put("filesAlreadyCurrent", report.filesAlreadyCurrent());
body.put("filesBackfilledFromDisk", report.filesBackfilledFromDisk());
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
* config; persisting an update against {@code mate_skill} would
* either silently no-op (no row to update) or — as users have hit —
* throw "技能不存在" because the lookup precedes the update. Sending
* a clear 4xx with a redirect hint is the right shape: the user
* wants the icon / display name / etc. to stick, and the only place
* those fields persist for an MCP entry is the MCP connection page.
*/
private void rejectVirtualSkillMutation(Long id) {
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)
|| vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
throw new vip.mate.exception.MateClawException(
"err.skill.virtual_readonly",
"MCP/ACP 衍生技能不可在此编辑——请到 Settings ▸ MCP/ACP 连接页修改");
}
}
/**
* Reject access to a skill that the request's workspace doesn't own.
* Builtin skills are global and exempt — every workspace may read and
* (where role permits) toggle them. The interceptor already verified
* the caller's role inside {@code workspaceId}; this guard closes the
* remaining gap where a member of workspace B targets a skill id that
* actually belongs to workspace A.
*/
private void verifyResourceWorkspace(SkillEntity skill, Long headerWorkspaceId) {
if (skill == null || Boolean.TRUE.equals(skill.getBuiltin())) {
return;
}
long requested = headerWorkspaceId != null
? headerWorkspaceId : SkillService.DEFAULT_WORKSPACE_ID;
long owner = skill.getWorkspaceId() != null
? skill.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID;
if (owner != requested) {
throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403,
"Skill " + skill.getId() + " does not belong to the current workspace");
}
}
@Operation(summary = "获取已启用技能列表")
@GetMapping("/enabled")
@RequireWorkspaceRole("member")
public R<List<SkillEntity>> listEnabled(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// Mirror the merging the paginated /skills endpoint does so the agent
// edit picker (which calls this endpoint) sees MCP- and ACP-derived
// virtual skills alongside the persisted ones. The shadow base must
// include all real skill names — including disabled ones — so a
// disabled real skill correctly suppresses its same-named virtual
// twin, matching /skills and /counts.
// The bridges surface disabled MCP/ACP servers too (so the Skills
// page can show a toggled-off card); this endpoint is enabled-only,
// so the virtual rows are filtered to enabled before merging.
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills(workspaceId));
Set<String> realNames = realSkillNames(workspaceId);
try {
result.addAll(enabledOnly(filterShadowedVirtualSkills(
mcpSkillBridge.listMcpDerivedSkillEntities(), realNames)));
} catch (Exception e) {
// Bridge failure must not 500 the picker — same defensive stance as /counts.
}
try {
result.addAll(enabledOnly(filterShadowedVirtualSkills(
acpSkillBridge.listAcpDerivedSkillEntities(), realNames)));
} catch (Exception e) {
// Bridge failure must not 500 the picker — same defensive stance as /counts.
}
return R.ok(result);
}
@Operation(summary = "按类型获取技能列表")
@GetMapping("/type/{skillType}")
@RequireWorkspaceRole("member")
public R<List<SkillEntity>> listByType(@PathVariable String skillType,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(skillService.listSkillsByType(skillType, workspaceId));
}
@Operation(summary = "获取已启用技能摘要(按类型分组)")
@GetMapping("/summary")
@RequireWorkspaceRole("member")
public R<Map<String, List<String>>> summary(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(skillService.getEnabledSkillSummary(workspaceId));
}
@Operation(summary = "获取技能详情")
@GetMapping("/{id}")
@RequireWorkspaceRole("member")
public R<SkillEntity> get(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// RFC-090 §3.2 — virtual MCP-derived skills synthesize a row
// on demand from the live MCP server entity.
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) {
List<SkillEntity> virt = mcpSkillBridge.listMcpDerivedSkillEntities();
return virt.stream()
.filter(s -> id.equals(s.getId()))
.findFirst()
.map(R::ok)
.orElse(R.fail("MCP-derived skill not found: " + id));
}
// RFC-090 §3.2 (parallel) — same path for ACP-derived virtual skills.
if (vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
SkillEntity ent = acpSkillBridge.findEntityById(id);
return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id);
}
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
// Read-time store reconciliation: the workspace SKILL.md may have
// been edited out-of-band (agent shell tools in a chat session)
// with no refresh in between. One file read + hash compare in the
// steady state; when the file side actually changed, the content
// is ingested and a single-skill rescan re-projects manifest
// columns and drops stale runtime caches — so the detail view
// always shows what the runtime executes.
try {
if (skillPackageResolver.reconcileEntityContent(skill)) {
skillRuntimeService.rescanSingle(skill);
}
} catch (Exception ignored) {
// A reconcile failure must not break the detail view.
}
return R.ok(skill);
}
@Operation(summary = "创建技能")
@PostMapping
@RequireWorkspaceRole("admin")
public R<SkillEntity> create(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestBody SkillEntity skill) {
// Always stamp the owning workspace from the request context — never
// trust a workspaceId in the request body.
skill.setWorkspaceId(workspaceId != null
? workspaceId : SkillService.DEFAULT_WORKSPACE_ID);
return R.ok(skillService.createSkill(skill));
}
@Operation(summary = "更新技能")
@PutMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<SkillEntity> update(@PathVariable Long id, @RequestBody SkillEntity skill,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
skill.setId(id);
return R.ok(skillService.updateSkill(skill));
}
/**
* RFC-090 §14.5 — admin-only hard delete: physical row removal +
* workspace purge. UI surfaces this as "permanently delete" and
* confirms with a destructive warning. The routine user-facing
* "remove" button on the skill card calls
* {@code DELETE /skills/install/{name}} instead, which goes through
* {@link vip.mate.skill.installer.SkillInstaller#uninstall} for the
* recoverable logical-delete + archive path.
*/
@Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)")
@DeleteMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<Void> delete(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
skillService.hardDeleteSkill(id);
return R.ok();
}
@Operation(summary = "启用/禁用技能")
@PutMapping("/{id}/toggle")
@RequireWorkspaceRole("admin")
public R<SkillEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// A virtual MCP skill mirrors an MCP server — toggling it enables /
// disables that server, keeping the Skills page and Settings ▸ MCP
// Connections in sync. ACP virtual skills have no such mapping and
// stay read-only via rejectVirtualSkillMutation below.
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) {
return R.ok(mcpSkillBridge.toggleVirtualSkill(id, enabled));
}
rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
return R.ok(skillService.toggleSkill(id, enabled));
}
@Operation(summary = "预览技能 Prompt 增强效果(调试用,与 Agent 真实运行时一致)")
@GetMapping("/prompt-preview")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> promptPreview() {
String prompt = skillRuntimeService.buildSkillPromptEnhancement();
return R.ok(Map.of(
"actualLength", prompt.length(),
"estimatedTokens", prompt.length() / 3,
"prompt", prompt
));
}
// ==================== Runtime API ====================
@Operation(summary = "获取 active skills 运行时视图")
@GetMapping("/runtime/active")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> getActiveSkills() {
List<ResolvedSkill> skills = skillRuntimeService.getActiveSkills();
return R.ok(Map.of("count", skills.size(), "skills", skills));
}
@Operation(summary = "获取所有技能的运行时解析状态(管理页面使用)")
@GetMapping("/runtime/status")
@RequireWorkspaceRole("admin")
public R<List<ResolvedSkill>> getRuntimeStatus() {
return R.ok(skillRuntimeService.resolveAllSkillsStatus());
}
@Operation(summary = "刷新 active skills 缓存resync=true 时同步内置技能到 workspace")
@PostMapping("/runtime/refresh")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> refreshRuntime(
@RequestParam(defaultValue = "false") boolean resync) {
List<String> resynced = List.of();
if (resync) {
resynced = bundledSkillSyncer.sync();
}
List<ResolvedSkill> skills = skillRuntimeService.refreshActiveSkills();
return R.ok(Map.of(
"count", skills.size(),
"message", resync ? "Active skills refreshed with workspace resync" : "Active skills refreshed",
"resynced", resynced));
}
// ==================== Requirements API (RFC-090 §7) ====================
/**
* RFC-090 §7 — pre-flight check: returns the per-requirement status
* for the skill so the install dialog and the detail drawer can render
* "✓ ffmpeg detected / ✗ groq_key missing" rows.
*
* <p>Calls the same {@link SkillDependencyChecker} the runtime uses,
* so the UI never disagrees with the runtime gate.
*/
@Operation(summary = "Pre-flight requirement statuses for a skill (RFC-090)")
@GetMapping("/{id}/requirements")
@RequireWorkspaceRole("member")
public R<Map<String, Object>> requirements(@PathVariable Long id) {
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(r -> r != null && id.equals(r.getId()))
.findFirst()
.orElse(null);
if (resolved == null) {
return R.fail("Skill not found: " + id);
}
SkillManifest manifest = resolved.getManifest();
if (manifest == null) {
// Legacy skill — fall back to dependency summary already on the resolved view.
return R.ok(Map.of(
"allMet", resolved.isDependencyReady(),
"statuses", List.of(),
"summary", resolved.getDependencySummary() != null ? resolved.getDependencySummary() : ""
));
}
List<Map<String, Object>> statuses = new ArrayList<>();
boolean allMet = true;
for (SkillManifest.RequirementDef req : manifest.getRequires()) {
SkillDependencyChecker.RequirementStatus st = dependencyChecker.checkRequirement(req);
boolean satisfied = st == SkillDependencyChecker.RequirementStatus.SATISFIED;
if (!satisfied && !req.isOptional()) allMet = false;
Map<String, Object> row = new LinkedHashMap<>();
row.put("key", req.getKey());
row.put("type", req.getType());
row.put("description", req.getDescription());
row.put("optional", req.isOptional());
row.put("status", st.name());
row.put("satisfied", satisfied);
row.put("installCommands", req.getInstall());
statuses.add(row);
}
return R.ok(Map.of(
"allMet", allMet,
"statuses", statuses,
"featureStatuses", resolved.getFeatureStatuses(),
"activeFeatures", resolved.getActiveFeatures()
));
}
// ==================== Reverse lookup (RFC-090 §7) ====================
/**
* RFC-090 §7 / §14.2 — list agents (employees) for which this
* skill is reachable.
*
* <p>Two coverage paths, both reflected in the response:
* <ol>
* <li><b>Explicit</b> — there's a {@code mate_agent_skill} row
* with this skill id and {@code enabled=true}.</li>
* <li><b>Implicit</b> — the agent has no explicit skill binding
* at all ({@code AgentBindingService.getBoundSkillIds}
* returns null), which the three-state contract treats as
* "use every globally-enabled skill". Most users never wire
* explicit bindings, so without this branch the count was
* always zero even for skills clearly visible to the LLM.</li>
* </ol>
*
* <p>Each row carries {@code binding: "explicit" | "implicit"} so
* the UI can label the relationship.
*/
@Operation(summary = "List agents that can use this skill (RFC-090 §14.2)")
@GetMapping("/{id}/employees")
@RequireWorkspaceRole("member")
public R<List<Map<String, Object>>> employees(@PathVariable Long id) {
// Explicit bindings: agent_skill rows pointing to this skill.
List<AgentSkillBinding> explicitBindings = agentSkillBindingMapper.selectList(
new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getSkillId, id)
.eq(AgentSkillBinding::getEnabled, true));
java.util.Set<Long> explicitAgentIds = new java.util.LinkedHashSet<>();
for (AgentSkillBinding b : explicitBindings) explicitAgentIds.add(b.getAgentId());
// Implicit bindings: agents whose getBoundSkillIds() == null
// (no explicit row in agent_skill at all). They get every
// globally-enabled skill, which includes this one provided the
// skill itself is enabled.
List<AgentEntity> allAgents = agentService.listAgents();
java.util.Set<Long> implicitAgentIds = new java.util.LinkedHashSet<>();
SkillEntity skill;
try {
skill = skillService.getSkill(id);
} catch (Exception e) {
skill = null;
}
boolean skillGloballyAvailable = skill != null && Boolean.TRUE.equals(skill.getEnabled());
if (skillGloballyAvailable) {
for (AgentEntity agent : allAgents) {
if (explicitAgentIds.contains(agent.getId())) continue;
java.util.Set<Long> bound = agentBindingService.getBoundSkillIds(agent.getId());
// null → no explicit bindings → uses every enabled skill
if (bound == null) implicitAgentIds.add(agent.getId());
}
}
java.util.List<Map<String, Object>> rows = new ArrayList<>();
// Stable order: explicit first (most "intentional" relationship),
// then implicit, agents inside each group keep DB insert order.
for (Long agentId : explicitAgentIds) {
appendAgentRow(rows, agentId, "explicit");
}
for (Long agentId : implicitAgentIds) {
appendAgentRow(rows, agentId, "implicit");
}
return R.ok(rows);
}
private void appendAgentRow(List<Map<String, Object>> rows, Long agentId, String binding) {
try {
AgentEntity agent = agentService.getAgent(agentId);
if (agent == null) return;
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", agent.getId());
row.put("name", agent.getName());
row.put("icon", agent.getIcon());
row.put("binding", binding);
rows.add(row);
} catch (Exception ignored) {
// agent may have been deleted; skip rather than fail the whole list
}
}
// ==================== Lessons API (RFC-090 §7 + §11.4) ====================
/**
* Read the per-skill {@code LESSONS.md} body for the detail drawer.
* Returns {@code entries: []} when the file is missing.
*/
@Operation(summary = "Read per-skill LESSONS.md (RFC-090 §11.4)")
@GetMapping("/{id}/lessons")
@RequireWorkspaceRole("member")
public R<Map<String, Object>> getLessons(@PathVariable Long id) {
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(r -> r != null && id.equals(r.getId()))
.findFirst()
.orElse(null);
if (resolved == null) return R.fail("Skill not found: " + id);
String body = lessonsService.readLessons(resolved);
// entryCount = number of "## " headed sections (RFC-090 §11.4 "💡 N entries" badge).
int entryCount = 0;
if (body != null && !body.isBlank()) {
int from = 0;
while (true) {
int idx = body.indexOf("\n## ", from);
if (idx < 0) break;
entryCount++;
from = idx + 1;
}
// Also count a leading "## " (no preceding newline) at start of file.
if (body.startsWith("## ")) entryCount++;
}
return R.ok(Map.of(
"skillId", id,
"skillName", resolved.getName(),
"raw", body == null ? "" : body,
"entryCount", entryCount
));
}
@Operation(summary = "Clear all lessons for a skill (RFC-090 §11.4)")
@PostMapping("/{id}/lessons/clear")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> clearLessons(@PathVariable Long id) {
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(r -> r != null && id.equals(r.getId()))
.findFirst()
.orElse(null);
if (resolved == null) return R.fail("Skill not found: " + id);
boolean cleared = lessonsService.clearLessons(resolved);
return R.ok(Map.of("cleared", cleared));
}
// ==================== Synthesis API (RFC-023) ====================
@Operation(summary = "从对话历史合成 SkillRFC-023")
@PostMapping("/synthesize-from-conversation")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> synthesizeFromConversation(@RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
String conversationId = (String) body.get("conversationId");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
if (conversationId == null || conversationId.isBlank()) {
return R.fail("conversationId is required");
}
SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(
conversationId, agentId, workspaceId);
if (result.blocked()) {
return R.ok(Map.of(
"success", false,
"blocked", true,
"skillName", result.skillName() != null ? result.skillName() : "",
"error", result.error(),
"scanSummary", result.scanSummary() != null ? result.scanSummary() : ""
));
}
if (!result.success()) {
return R.ok(Map.of("success", false, "error", result.error()));
}
return R.ok(Map.of(
"success", true,
"skillId", result.skillId(),
"skillName", result.skillName()
));
}
// ==================== Workspace API ====================
@Operation(summary = "将 skill 导出到工作区目录")
@PostMapping("/{id}/export-workspace")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> exportToWorkspace(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent());
if (path == null) {
return R.ok(Map.of("success", false, "message", "Failed to export workspace"));
}
return R.ok(Map.of("success", true, "path", path.toString()));
}
@Operation(summary = "获取 skill 工作区信息")
@GetMapping("/{id}/workspace")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> getWorkspaceInfo(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
return R.ok(workspaceManager.getWorkspaceInfo(skill.getName()));
}
// ==================== Skill lifecycle & curator ====================
@Operation(summary = "钉住/取消钉住技能(钉住的技能不会被自动归档)")
@PostMapping("/{id}/pin")
@RequireWorkspaceRole("admin")
public R<SkillEntity> pin(@PathVariable Long id,
@RequestBody(required = false) PinRequest body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
boolean pinned = body != null && Boolean.TRUE.equals(body.pinned());
return R.ok(skillLifecycleService.setPinned(id, pinned));
}
@Operation(summary = "手动归档技能")
@PostMapping("/{id}/archive")
@RequireWorkspaceRole("admin")
public R<SkillEntity> archive(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean force,
@RequestBody(required = false) ArchiveRequest 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())) {
throw new MateClawException("err.skill.builtin_not_archivable", 400,
"Cannot archive builtin skill: " + skill.getName());
}
String state = skill.getLifecycleState() == null ? "active" : skill.getLifecycleState();
if ("archived".equals(state)) {
throw new MateClawException("err.skill.already_archived", 409,
"Skill already archived: " + skill.getName());
}
// Bound skills are not silently archived: require an explicit
// second-pass confirmation (force=true) so the admin sees which
// agents lose the capability.
if (!force) {
List<ConfirmRequiredException.AgentRow> bound =
agentBindingService.enabledAgentsBoundToSkill(id);
if (!bound.isEmpty()) {
throw new ConfirmRequiredException("BOUND_SKILL_CONFIRM_REQUIRED",
"Skill is explicitly bound to " + bound.size()
+ " agent(s); pass force=true to confirm", bound);
}
}
String reason = body != null && body.reason() != null ? body.reason() : "manual:admin";
skillLifecycleService.applyManual(skill, LifecycleTransition.TO_ARCHIVED,
LocalDateTime.now(), reason);
return R.ok(skillService.getSkill(id));
}
@Operation(summary = "恢复已归档的技能")
@PostMapping("/{id}/restore")
@RequireWorkspaceRole("admin")
public R<SkillEntity> restore(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
return R.ok(skillLifecycleService.restore(id));
}
@Operation(summary = "立即运行一次 curator 预览dry-run")
@PostMapping("/curator/dry-run")
@RequireWorkspaceRole("admin")
public R<SkillCuratorReport> curatorDryRun() {
return R.ok(skillCuratorJob.dryRunNow());
}
@Operation(summary = "激活/取消激活 curator真正归档 vs 仅预览)")
@PostMapping("/curator/activate")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorActivate(
@RequestParam(defaultValue = "true") boolean activate) {
skillCuratorJob.activate(activate);
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "暂停 curator 定时扫描")
@PostMapping("/curator/pause")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorPause() {
skillCuratorJob.setPaused(true);
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "恢复 curator 定时扫描")
@PostMapping("/curator/resume")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorResume() {
skillCuratorJob.setPaused(false);
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "开启/关闭 curator 合并去重 pass")
@PostMapping("/curator/consolidate")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorConsolidate(
@RequestParam(defaultValue = "true") boolean enabled) {
skillCuratorJob.setConsolidate(enabled);
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "curator 控制面状态")
@GetMapping("/curator/status")
@RequireWorkspaceRole("member")
public R<Map<String, Object>> curatorStatus() {
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "列出最近的 curator 运行报告")
@GetMapping("/curator/reports")
@RequireWorkspaceRole("member")
public R<List<String>> curatorReports() {
return R.ok(skillCuratorReportStore.listRunIds(20));
}
@Operation(summary = "读取某次 curator 运行报告")
@GetMapping("/curator/reports/{runId}")
@RequireWorkspaceRole("member")
public R<Object> curatorReport(@PathVariable String runId) {
Object report = skillCuratorReportStore.readRun(runId);
if (report == null) {
throw new MateClawException("err.skill.curator_report_not_found", 404,
"Curator report not found: " + runId);
}
return R.ok(report);
}
/** Body of {@code POST /skills/{id}/pin}. */
public record PinRequest(Boolean pinned) {}
/** Optional body of {@code POST /skills/{id}/archive}. */
public record ArchiveRequest(String reason) {}
}