mateclaw/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java

685 lines
32 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.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.SkillCatalogSort;
import vip.mate.skill.runtime.SkillCatalogSorter;
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 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 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;
@Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping
public R<IPage<SkillEntity>> list(
@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) 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);
List<SkillEntity> virtualSkills = visibleVirtualSkills(
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")
public R<Map<String, Long>> counts() {
Map<String, Long> result = skillService.countByType();
Set<String> realNames = realSkillNames();
// 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 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(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();
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);
}
private Set<String> realSkillNames() {
return skillService.listSkills().stream()
.map(SkillEntity::getName)
.collect(java.util.stream.Collectors.toSet());
}
@Operation(summary = "重新扫描单个技能RFC-042 §2.3.4")
@PostMapping("/{id}/rescan")
public R<SkillEntity> rescan(@PathVariable Long id) {
rejectVirtualSkillMutation(id);
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")
public R<Map<String, Object>> syncFiles(@PathVariable Long id) {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
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")
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);
}
/**
* 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 连接页修改");
}
}
@Operation(summary = "获取已启用技能列表")
@GetMapping("/enabled")
public R<List<SkillEntity>> listEnabled() {
// 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.
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills());
Set<String> realNames = realSkillNames();
try {
result.addAll(filterShadowedVirtualSkills(
mcpSkillBridge.listMcpDerivedSkillEntities(), realNames));
} catch (Exception e) {
// Bridge failure must not 500 the picker — same defensive stance as /counts.
}
try {
result.addAll(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}")
public R<List<SkillEntity>> listByType(@PathVariable String skillType) {
return R.ok(skillService.listSkillsByType(skillType));
}
@Operation(summary = "获取已启用技能摘要(按类型分组)")
@GetMapping("/summary")
public R<Map<String, List<String>>> summary() {
return R.ok(skillService.getEnabledSkillSummary());
}
@Operation(summary = "获取技能详情")
@GetMapping("/{id}")
public R<SkillEntity> get(@PathVariable Long id) {
// 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);
}
return R.ok(skillService.getSkill(id));
}
@Operation(summary = "创建技能")
@PostMapping
public R<SkillEntity> create(@RequestBody SkillEntity skill) {
return R.ok(skillService.createSkill(skill));
}
@Operation(summary = "更新技能")
@PutMapping("/{id}")
public R<SkillEntity> update(@PathVariable Long id, @RequestBody SkillEntity skill) {
rejectVirtualSkillMutation(id);
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}")
public R<Void> delete(@PathVariable Long id) {
rejectVirtualSkillMutation(id);
skillService.hardDeleteSkill(id);
return R.ok();
}
@Operation(summary = "启用/禁用技能")
@PutMapping("/{id}/toggle")
public R<SkillEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
rejectVirtualSkillMutation(id);
return R.ok(skillService.toggleSkill(id, enabled));
}
@Operation(summary = "预览技能 Prompt 增强效果(调试用,与 Agent 真实运行时一致)")
@GetMapping("/prompt-preview")
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")
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")
public R<List<ResolvedSkill>> getRuntimeStatus() {
return R.ok(skillRuntimeService.resolveAllSkillsStatus());
}
@Operation(summary = "刷新 active skills 缓存resync=true 时同步内置技能到 workspace")
@PostMapping("/runtime/refresh")
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")
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")
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")
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")
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")
public R<Map<String, Object>> synthesizeFromConversation(@RequestBody Map<String, Object> body) {
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);
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")
public R<Map<String, Object>> exportToWorkspace(@PathVariable Long id) {
SkillEntity skill = skillService.getSkill(id);
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")
public R<Map<String, Object>> getWorkspaceInfo(@PathVariable Long id) {
SkillEntity skill = skillService.getSkill(id);
return R.ok(workspaceManager.getWorkspaceInfo(skill.getName()));
}
}