diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index a2c269c7..4e5037c7 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -6,6 +6,11 @@ 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 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; @@ -41,6 +46,8 @@ public class SkillController { private final SkillSynthesisService synthesisService; private final SkillDependencyChecker dependencyChecker; private final SkillLessonsService lessonsService; + private final AgentSkillBindingMapper agentSkillBindingMapper; + private final AgentService agentService; @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @GetMapping @@ -220,6 +227,38 @@ public class SkillController { )); } + // ==================== Reverse lookup (RFC-090 §7) ==================== + + /** + * RFC-090 §7 — list agents (employees) currently bound to this + * skill so the skill card can render "Used by N employees" with a + * click-through. Returns lightweight {id, name, icon} rows; the + * full agent object would balloon the card payload. + */ + @Operation(summary = "List agents bound to this skill (RFC-090)") + @GetMapping("/{id}/employees") + public R>> employees(@PathVariable Long id) { + List bindings = agentSkillBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(AgentSkillBinding::getSkillId, id) + .eq(AgentSkillBinding::getEnabled, true)); + List> rows = new ArrayList<>(bindings.size()); + for (AgentSkillBinding b : bindings) { + try { + AgentEntity agent = agentService.getAgent(b.getAgentId()); + if (agent == null) continue; + Map row = new LinkedHashMap<>(); + row.put("id", agent.getId()); + row.put("name", agent.getName()); + row.put("icon", agent.getIcon()); + rows.add(row); + } catch (Exception ignored) { + // agent may have been deleted; skip rather than fail the whole list + } + } + return R.ok(rows); + } + // ==================== Lessons API (RFC-090 §7 + §11.4) ==================== /** @@ -235,10 +274,24 @@ public class SkillController { .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 + "raw", body == null ? "" : body, + "entryCount", entryCount )); } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 1b6c0bd2..15e0df61 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -170,10 +170,11 @@ export const skillApi = { refreshRuntime: () => http.post('/skills/runtime/refresh'), exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`), getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`), - // RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + // RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + reverse lookup requirements: (id: string | number) => http.get(`/skills/${id}/requirements`), getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`), clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`), + employees: (id: string | number) => http.get(`/skills/${id}/employees`), } // ==================== ACP Endpoints (RFC-090 Phase 7) ==================== diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 7e48e350..2823bbed 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1996,6 +1996,13 @@ export default { mcp: 'MCP', dynamic: 'Dynamic', }, + source: { + builtin: 'Built-in', + synthesized: 'AI Synthesized', + local: 'Local', + }, + usedByTitle: 'Number of agents that have this skill bound', + lessonsCountTitle: 'Lessons recorded — click to view', actions: { configure: 'Configure', view: 'View', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 6c43b2b3..892584b9 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1998,6 +1998,13 @@ export default { mcp: 'MCP', dynamic: '动态', }, + source: { + builtin: '内置', + synthesized: 'AI 合成', + local: '本地', + }, + usedByTitle: '已绑定此技能的 Agent 数量', + lessonsCountTitle: '已记录的 lessons — 点击查看', actions: { configure: '配置', view: '查看', diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index c502328c..69bde54f 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -126,6 +126,14 @@ {{ getFeaturesBadge(skill)?.label }} {{ getSourceBadge(skill) }} + + {{ getSourceLabel(skill) }} + + 👥 {{ getUsedByCount(skill) }} + + + 💡 {{ getLessonsCount(skill) }} + {{ getRuntimePath(skill) }} @@ -455,6 +463,10 @@ const detailTab = ref<'manifest' | 'tools' | 'features' | 'lessons'>('manifest') const detailLessonsRaw = ref('') const detailLessonsLoading = ref(false) +/** RFC-090 §4.2 card surface — per-skill side data (lessons count, used-by). */ +const lessonsCountBySkill = ref>({}) +const usedByBySkill = ref>({}) + const detailRuntime = computed(() => detailSkill.value ? runtimeStatusMap.value[detailSkill.value.name] || null : null, ) @@ -615,6 +627,9 @@ async function loadSkills() { } else { total.value = 0 } + // RFC-090 §4.2 — fire-and-forget load card side data (lessons / + // used-by counts) for the currently visible page. + loadCardSideData() } catch (e) { skills.value = [] total.value = 0 @@ -928,6 +943,70 @@ function getSourceBadge(skill: Skill): string { return '' } +/** + * RFC-090 §4.2 — Source label for the skill card. + * + * Derivation precedence: + * 1. builtin=true → "Built-in" + * 2. skillType=mcp → "MCP" + * 3. skillType=acp → "ACP" (Phase 7) + * 4. sourceConversationId set → "AI Synthesized" (RFC-023) + * 5. configJson.source.type / upstream → "ClawHub" / "GitHub" + * 6. fallback → "Local" + */ +function getSourceLabel(skill: Skill): string { + if (skill.builtin) return t('skills.source.builtin') + if (skill.skillType === 'mcp') return 'MCP' + if (skill.skillType === 'acp') return 'ACP' + if (skill.sourceConversationId) return t('skills.source.synthesized') + try { + const config = skill.configJson ? JSON.parse(skill.configJson) : null + const sourceType = config?.source?.type || config?.upstream + if (sourceType === 'clawhub') return 'ClawHub' + if (sourceType === 'github') return 'GitHub' + } catch { /* ignore */ } + return t('skills.source.local') +} + +function getSourceClass(skill: Skill): string { + if (skill.builtin) return 'src-builtin' + if (skill.skillType === 'mcp' || skill.skillType === 'acp') return 'src-protocol' + if (skill.sourceConversationId) return 'src-synth' + return 'src-local' +} + +function getLessonsCount(skill: Skill): number { + return lessonsCountBySkill.value[String(skill.id)] || 0 +} + +function getUsedByCount(skill: Skill): number { + return usedByBySkill.value[String(skill.id)] || 0 +} + +/** + * Batch-load lessons count + used-by count for the currently rendered + * skill list. Called after each loadSkills(); failures are silenced + * per-skill so a single 404 doesn't blank the entire counts map. + */ +async function loadCardSideData() { + const items = skills.value + if (items.length === 0) return + const tasks = items.map(async (skill) => { + const id = String(skill.id) + const calls = [ + skillApi.getLessons(skill.id).then((res: any) => { + lessonsCountBySkill.value[id] = res?.data?.entryCount || 0 + }).catch(() => { lessonsCountBySkill.value[id] = 0 }), + skillApi.employees(skill.id).then((res: any) => { + usedByBySkill.value[id] = Array.isArray(res?.data) ? res.data.length : 0 + }).catch(() => { usedByBySkill.value[id] = 0 }), + ] + await Promise.all(calls) + }) + // Don't block on the whole batch; render whatever lands first. + await Promise.allSettled(tasks) +} + function getSkillIcon(type: string) { return { builtin: '🔧', mcp: '🔌', dynamic: '📦' }[type] ?? '🛠️' } @@ -1203,6 +1282,16 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); } /* RFC-090 §14.1 — features 矩阵徽标 */ .rt-features-ready { background: rgba(34, 197, 94, 0.12); color: #16a34a; } .rt-features-mixed { background: rgba(99, 102, 241, 0.12); color: #6366f1; } + +/* RFC-090 §4.2 — Source label + Used-by + Lessons count */ +.source-label { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; } +.source-label.src-builtin { background: rgba(34, 197, 94, 0.12); color: #16a34a; } +.source-label.src-protocol { background: rgba(99, 102, 241, 0.12); color: #6366f1; } +.source-label.src-synth { background: rgba(168, 85, 247, 0.12); color: #a855f7; } +.source-label.src-local { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); } +.usedby-badge, .lessons-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); } +.lessons-badge { cursor: pointer; } +.lessons-badge:hover { background: var(--mc-primary-bg); color: var(--mc-primary); } :root.dark .rt-features-ready { background: rgba(34, 197, 94, 0.2); color: #4ade80; } :root.dark .rt-features-mixed { background: rgba(129, 140, 248, 0.18); color: #a5b4fc; } .rt-disabled { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }